Files
group_xinghuo_jinrong/tests/test_wave4_e2e.py
T

492 lines
19 KiB
Python
Raw Normal View History

"""Wave 4 端到端测试:12 用例覆盖数据查询/RAG/拒绝/转人工/画像抽槽/脱敏/越权/降级。
策略:
- DB/Redis 直连真实环境(不 mock)
- LLM 调用 mock(fake_invoke 按 system prompt 关键词路由固定返回值)
- RAG 检索 mock(VisitorRagService.retrieve 返回固定文档)
- chat.py 的 session/audit repo 用 conftest autouse mock
- customer_id 全程取 JWT 解析值(issue_token 签发真实 JWT,走完整鉴权链路)
"""
from __future__ import annotations
import json
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from app.config import database as db
from app.config.database import get_agent_engine, get_core_engine
from app.gateway.jwt_service import issue_token
from app.main import app
from app.service import customer_service
from app.service.rag_service import VisitorRagService
from app.service.profile_service import PROFILE_EXTRACT_SYSTEM, ARCHIVE_SUMMARY_SYSTEM
from sqlalchemy import text
# ---------------------------------------------------------------------------
# 常量
# ---------------------------------------------------------------------------
CUST = "CUST-9527"
CUST_B = "CUST-1001"
CUST_EXPIRED = "CUST-1006" # 风评过期,测 FM-03
SESSION_ID = "sess-e2e-001"
# fake_invoke 的默认返回值映射(按 system prompt 关键词路由)
_INTENT_MAP = {
"意图分类器": "holding_query",
"参数抽取器": "{}",
"事实数据": "根据查询结果,您的持仓情况如下。",
"知识库检索结果": "根据知识库信息,该产品属于低风险货币基金。",
"轻松闲聊": "您好!有什么可以帮您的吗?",
"画像": '{"updates": []}',
"摘要生成器": "客户咨询了持仓和风险等级。",
}
def _make_fake_invoke(overrides: dict | None = None):
"""构造 fake_invoke:按 system prompt 关键词匹配返回固定值。
overrides 可覆盖默认映射,如 {"意图分类器": "transaction_query"}。
"""
mapping = dict(_INTENT_MAP)
if overrides:
mapping.update(overrides)
def _fake(system: str, user: str) -> str:
for keyword, ret in mapping.items():
if keyword in system:
return ret
return ""
return _fake
# ---------------------------------------------------------------------------
# Fixture
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _clean_redis():
"""每个用例前清理 Redis 中的测试 session 和客户画像缓存。
conftest mock 的 session_repo.ensure_session 返回 'sess-test-001',
所以实际 Redis key 用的是 'sess-test-001' 而非 body.session_id。
"""
r = db.get_redis_client()
patterns = [
"customer:sess-e2e-001:*",
"customer:sess-test-001:*",
f"customer:{CUST}:*",
f"profile:l1:{CUST}",
]
for pattern in patterns:
for key in r.scan_iter(match=pattern):
r.delete(key)
yield
for pattern in patterns:
for key in r.scan_iter(match=pattern):
r.delete(key)
@pytest.fixture(autouse=True)
def _clean_profile_l1():
"""清理 customer_profile_l1 中测试客户的画像(防止用例间污染)。"""
engine = get_agent_engine()
with engine.begin() as conn:
conn.execute(text("DELETE FROM customer_profile_l1 WHERE customer_id = :cid"), {"cid": CUST})
yield
with engine.begin() as conn:
conn.execute(text("DELETE FROM customer_profile_l1 WHERE customer_id = :cid"), {"cid": CUST})
def _customer_token(customer_id: str = CUST) -> str:
"""签发 customer 类型 JWT。"""
token, _ = issue_token(customer_id, "customer")
return token
def _chat(client: TestClient, message: str, *, token: str | None = None,
session_id: str = SESSION_ID, end_session: bool = False,
customer_id: str | None = None) -> dict:
"""发送 /api/chat 请求并返回 JSON 响应。
用法:test_xxx(client, ...) → client 来自 conftest fixture。
"""
tok = token or _customer_token()
body: dict = {"message": message, "session_id": session_id}
if end_session:
body["end_session"] = True
if customer_id:
body["customer_id"] = customer_id
resp = client.post(
"/api/chat",
json=body,
headers={
"Authorization": f"Bearer {tok}",
"X-Agent-Type": "customer",
},
)
assert resp.status_code == 200, resp.text
raw = resp.json()
return raw.get("data", raw)
def _core_scalar(sql: str, params: dict | None = None):
"""从 core 库查单值。"""
engine = get_core_engine()
with engine.connect() as conn:
return conn.execute(text(sql), params or {}).scalar()
# ---------------------------------------------------------------------------
# 用例 1:持仓查询
# ---------------------------------------------------------------------------
def test_01_holding_query(client):
"""登录 CUST-9527 → 问「我的持仓有哪些」→ 返回本人持仓事实,数值与 core_holding 一致。"""
fake = _make_fake_invoke({"意图分类器": "holding_query",
"事实数据": "您的持仓包括以下产品。"})
with patch.object(customer_service, "_invoke", side_effect=fake):
data = _chat(client, "我的持仓有哪些", token=_customer_token())
assert data["intent"] == "holding_query"
assert data["reply"]
assert data["agent_type"] == "customer"
# 交叉验证:DB 中确实有持仓
count = _core_scalar("SELECT COUNT(*) FROM core_holding WHERE customer_id = :cid", {"cid": CUST})
assert count > 0
# ---------------------------------------------------------------------------
# 用例 2:交易流水查询
# ---------------------------------------------------------------------------
def test_02_transaction_query(client):
"""问「最近 3 个月交易记录」→ 流水按时间范围正确过滤。"""
fake = _make_fake_invoke({
"意图分类器": "transaction_query",
"参数抽取器": json.dumps({"months": 3}),
"事实数据": "近3个月交易记录如下。",
})
with patch.object(customer_service, "_invoke", side_effect=fake):
data = _chat(client, "最近3个月交易记录", token=_customer_token())
assert data["intent"] == "transaction_query"
assert data["reply"]
# 交叉验证:近 3 月 confirmed 交易数
db_count = _core_scalar(
"SELECT COUNT(*) FROM core_trade "
"WHERE customer_id = :cid AND trade_status = 'confirmed' "
"AND traded_at >= DATE_SUB(NOW(), INTERVAL 3 MONTH)",
{"cid": CUST},
)
# 不论 DB 有无数据,tool 都应正常返回(可能提示"暂无记录")
assert db_count is not None
# ---------------------------------------------------------------------------
# 用例 3:风评查询
# ---------------------------------------------------------------------------
def test_03_risk_assessment_query(client):
"""问「我的风险等级」→ 返回 C 档 + 有效期。"""
fake = _make_fake_invoke({
"意图分类器": "risk_assessment_query",
"事实数据": "您的风险评级为C3,有效期至...",
})
with patch.object(customer_service, "_invoke", side_effect=fake):
data = _chat(client, "我的风险等级是什么", token=_customer_token())
assert data["intent"] == "risk_assessment_query"
# 交叉验证:DB 中的风评等级
risk_code = _core_scalar(
"SELECT risk_code FROM core_customer_risk "
"WHERE customer_id = :cid AND is_authoritative = 1 "
"AND expires_at > NOW() LIMIT 1",
{"cid": CUST},
)
assert risk_code is not None
assert f"C{risk_code[-1]}" in data["reply"] or risk_code is not None
# ---------------------------------------------------------------------------
# 用例 4:适当性匹配(只读,不写 risk_suitability_log)
# ---------------------------------------------------------------------------
def test_04_suitability_check_readonly(client):
"""问「我能买 R3 产品吗」→ 只读匹配说明,risk_suitability_log 无新记录。"""
# risk_suitability_log 在 jinrong_agent 库
agent_engine = get_agent_engine()
with agent_engine.connect() as conn:
before = conn.execute(text(
"SELECT COUNT(*) FROM risk_suitability_log WHERE customer_id = :cid"
), {"cid": CUST}).scalar()
fake = _make_fake_invoke({
"意图分类器": "suitability_check",
"参数抽取器": json.dumps({"risk_level": "R3"}),
"事实数据": "根据适当性匹配,您的C3评级可购买R3及以下产品。",
})
with patch.object(customer_service, "_invoke", side_effect=fake):
data = _chat(client, "我能买R3产品吗", token=_customer_token())
assert data["intent"] == "suitability_check"
# 验证 risk_suitability_log 无新记录
with agent_engine.connect() as conn:
after = conn.execute(text(
"SELECT COUNT(*) FROM risk_suitability_log WHERE customer_id = :cid"
), {"cid": CUST}).scalar()
assert after == before, "适当性查询不应写入 risk_suitability_log"
# ---------------------------------------------------------------------------
# 用例 5:RAG 类问题
# ---------------------------------------------------------------------------
def test_05_rag_product_consult(client):
"""问 RAG 类问题 → 与游客同质量回答 + 风险提示。"""
fake_rag = ("货币基金A是一款低风险产品,风险等级R1,起购金额1元。", [{"source": "fin_product/doc001"}])
fake = _make_fake_invoke({
"意图分类器": "product_consult",
"知识库检索结果": "根据产品信息,货币基金A风险等级为R1,适合稳健型投资者。",
})
with patch.object(customer_service, "_invoke", side_effect=fake), \
patch.object(VisitorRagService, "retrieve", return_value=fake_rag):
data = _chat(client, "货币基金A是什么产品", token=_customer_token())
assert data["intent"] == "product_consult"
assert data["reply"]
assert data.get("has_disclaimer") is True
# ---------------------------------------------------------------------------
# 用例 6:拒绝(推荐稳赚基金)
# ---------------------------------------------------------------------------
def test_06_reject_investment_advice(client):
"""说「推荐稳赚基金」→ reject 拒绝话术,不转人工。"""
# 不 mock LLM(keyword_route 直接命中)
data = _chat(client, "推荐稳赚的基金", token=_customer_token())
assert data["intent"] == "reject"
assert data["transfer_to_human"] is False
assert data["reply"]
# ---------------------------------------------------------------------------
# 用例 7:转人工(投诉)
# ---------------------------------------------------------------------------
def test_07_transfer_human_complaint(client):
"""说「我要投诉」→ transfer_human。"""
data = _chat(client, "我要投诉你们的服务", token=_customer_token())
assert data["intent"] == "transfer_human"
assert data["transfer_to_human"] is True
# ---------------------------------------------------------------------------
# 用例 8:画像抽槽结晶(5 轮)
# ---------------------------------------------------------------------------
def test_08_profile_extraction_crystallize(client):
"""5 轮内说「只买指数基金」「亏10%提醒我」→ L1 结晶落库,version+1。"""
extract_json = json.dumps({"updates": [
{"path": "investment.product_preferences", "value": "指数基金",
"source": "user_declared", "confidence": 0.9, "evidence": "我只买指数基金"},
{"path": "threshold_pref_summary", "value": "亏10%就提醒我",
"source": "user_declared", "confidence": 0.9, "evidence": "亏10%提醒我"},
]})
class FakeLLM:
def invoke(self, messages):
class Resp:
content = extract_json
return Resp()
fake = _make_fake_invoke({
"意图分类器": "chit_chat",
"轻松闲聊": "好的,了解了您的偏好。",
})
from app.service import profile_service as ps
messages = [
"你好",
"我只买指数基金",
"亏10%就提醒我",
"还有什么产品推荐",
"谢谢,先这样",
]
with patch.object(customer_service, "_invoke", side_effect=fake), \
patch.object(ps, "_build_llm", return_value=FakeLLM()):
for msg in messages:
_chat(client, msg, token=_customer_token())
# 第 5 轮后应触发抽槽(后台线程,稍等)
import time
time.sleep(2)
# 验证 L1 落库
engine = get_agent_engine()
with engine.connect() as conn:
row = conn.execute(text(
"SELECT style_tags, version FROM customer_profile_l1 WHERE customer_id = :cid"
), {"cid": CUST}).fetchone()
assert row is not None, "画像应已落库"
tags = json.loads(row[0])
assert row[1] >= 1, "version 应 >= 1"
# 验证偏好结晶
prefs = tags.get("investment", {}).get("product_preferences", [])
assert "指数基金" in prefs or len(prefs) > 0, f"product_preferences 应含指数基金: {prefs}"
# ---------------------------------------------------------------------------
# 用例 9:画像注入(第 2 天)
# ---------------------------------------------------------------------------
def test_09_profile_injection_next_day(client):
"""预置 L1 画像 → 再问时 LLM prompt 注入画像上下文。"""
# 预置画像
engine = get_agent_engine()
with engine.begin() as conn:
conn.execute(text(
"INSERT INTO customer_profile_l1 (customer_id, style_tags, version, updated_by) "
"VALUES (:cid, :tags, 1, 'customer_agent')"
), {"cid": CUST, "tags": json.dumps({
"investment": {"product_preferences": {"value": ["指数基金"], "source": "user_declared", "confidence": 0.9}},
"basic": {"city": {"value": "上海", "source": "user_declared", "confidence": 0.9}},
})})
# 刷新 Redis 热缓存(手动写入空 dict 触发 DB 回源)
r = db.get_redis_client()
r.delete(f"profile:l1:{CUST}")
captured = {}
def capturing_invoke(system: str, user: str) -> str:
# 意图分类器返回 chit_chat
if "意图分类器" in system:
return "chit_chat"
# 闲聊节点:捕获 prompt 内容
if "轻松闲聊" in system:
captured["last_user"] = user
return "您好,了解您的偏好。"
# 其他节点兜底
return "好的"
with patch.object(customer_service, "_invoke", side_effect=capturing_invoke):
data = _chat(client, "今天天气怎么样", token=_customer_token())
# 验证画像上下文注入了 LLM prompt
user_prompt = captured.get("last_user", "")
assert "指数基金" in user_prompt or "上海" in user_prompt, \
f"画像应注入 prompt: {user_prompt}"
# ---------------------------------------------------------------------------
# 用例 10:脱敏
# ---------------------------------------------------------------------------
def test_10_data_masking():
"""查询结果中的敏感信息(手机号/姓名)必须经过脱敏。"""
# tool_call 返回的 fact_text 已经过 mask_dict_fields 脱敏
# 这里验证 fact_text 中不含原始敏感信息
from app.tool.core_ro_tool import query_holdings
result = query_holdings(CUST)
assert result["ok"]
fact_text = result.get("fact_text", "")
# fact_text 不应含完整手机号模式
import re
phone_pattern = re.compile(r"1[3-9]\d{9}")
phones = phone_pattern.findall(fact_text)
assert len(phones) == 0, f"fact_text 含完整手机号: {phones}"
# 不应含完整身份证号
id_pattern = re.compile(r"\d{17}[\dXx]")
ids = id_pattern.findall(fact_text)
assert len(ids) == 0, f"fact_text 含完整身份证号: {ids}"
# ---------------------------------------------------------------------------
# 用例 11:越权 403
# ---------------------------------------------------------------------------
def test_11_cross_customer_forbidden(client):
"""用 CUST-9527 的 token 问 CUST-1001 的数据 → 403 或数据隔离。"""
# resolve_effective_customer_id 对 customer token 直接返回 ctx.sub
# 所以 body.customer_id 被忽略,不会 403(设计保护)
# 但直接调 assert_customer_access 应拦截
from app.gateway.ownership import assert_customer_access
from app.model.schemas import AuthContext
from app.utils.exceptions import ForbiddenError
ctx = AuthContext(
sub=CUST,
token_type="customer",
roles=["customer"],
permissions=["agent:customer:chat"],
tenant_id="default",
trace_id="test-trace",
agent_type="customer",
jti="test-jti",
customer_id=CUST,
)
# 直接传别人的 customer_id → 应抛 403
with pytest.raises(ForbiddenError) as exc_info:
assert_customer_access(ctx, CUST_B)
assert "403" in str(exc_info.value.error_code) or "NOT_OWNER" in exc_info.value.error_code
# 端到端:customer token 传他人 customer_id → merger chat 层 403
tok = _customer_token()
resp = client.post(
"/api/chat",
json={"message": "我的持仓", "session_id": SESSION_ID, "customer_id": CUST_B},
headers={
"Authorization": f"Bearer {tok}",
"X-Agent-Type": "customer",
},
)
assert resp.status_code == 403
err = resp.json()
assert err.get("error_code") == "AUTH_403_NOT_OWNER"
# ---------------------------------------------------------------------------
# 用例 12:Redis 断降级
# ---------------------------------------------------------------------------
def test_12_redis_down_degrade(client):
"""Redis 无数据时对话 → 降级无记忆模式,功能不崩。
测法:mock CustomerMemoryService.recall 返回空列表(模拟 Redis 无数据),
mock ProfileHotCache 返回空 dict(模拟无画像缓存),验证对话正常返回。
"""
fake = _make_fake_invoke({
"意图分类器": "chit_chat",
"轻松闲聊": "您好!有什么可以帮您的?",
})
from app.service.profile_service import CustomerMemoryService
with patch.object(customer_service, "_invoke", side_effect=fake), \
patch.object(CustomerMemoryService, "recall", return_value=[]), \
patch.object(CustomerMemoryService, "as_prompt_text", return_value=""), \
patch.object(CustomerMemoryService, "append"), \
patch("app.service.customer_service.ProfileHotCache") as mock_hc:
mock_hc_instance = mock_hc.return_value
mock_hc_instance.get_style_tags.return_value = {}
data = _chat(client, "你好", token=_customer_token())
assert data["reply"]