"""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"]