合并yy并同步远程qyqy_develop
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""真实 MySQL:管理员待审队列(A047)与审核链路的可达性。
|
||||
|
||||
## 为什么单独守这一条
|
||||
|
||||
`review` / `publish` 都要求调用方**先拿到键** —— 推荐方案是 `content_id`、
|
||||
投资方案书是 `goal_no`。在 A047 之前**没有任何端点能列出待审内容**,
|
||||
管理员拿不到键,于是投顾生成的东西永远停在待审状态、没有人能推进。
|
||||
|
||||
本文件守住三件事:
|
||||
1. 管理员能读到待审队列;
|
||||
2. **投顾读不到**(这条队列是管理面的,权限比 `published` 更严);
|
||||
3. **方案书必须带 `goal_no`** —— 否则前端拿到列表也调不动 AD006/AD007。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
PENDING_PATH = "/api/v1/admin/advisor/pending-contents"
|
||||
|
||||
BOOK_TYPE = "investment_goal_book"
|
||||
RECOMMENDATION_TYPE = "advisor_recommendation_plan"
|
||||
|
||||
|
||||
async def _token(client: httpx.AsyncClient, username: str, password: str) -> str:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/tokens", json={"username": username, "password": password}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["data"]["access_token"]
|
||||
|
||||
|
||||
async def test_admin_can_read_pending_queue_and_advisor_cannot() -> None:
|
||||
app = create_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://test", timeout=30
|
||||
) as client:
|
||||
admin = await _token(client, "admin_t", "88888888")
|
||||
advisor = await _token(client, "advisor_t", "abc12345")
|
||||
|
||||
admin_response = await client.get(
|
||||
PENDING_PATH, headers={"Authorization": f"Bearer {admin}"}
|
||||
)
|
||||
advisor_response = await client.get(
|
||||
PENDING_PATH, headers={"Authorization": f"Bearer {advisor}"}
|
||||
)
|
||||
|
||||
assert admin_response.status_code == 200, admin_response.text
|
||||
items = admin_response.json()["data"]
|
||||
assert isinstance(items, list)
|
||||
|
||||
# 投顾不能读管理面队列(权限是 `product-recommendation:review` + admin)
|
||||
assert advisor_response.status_code == 403, advisor_response.text
|
||||
assert advisor_response.json()["error"]["code"] == "AGENT_PERMISSION_DENIED"
|
||||
|
||||
|
||||
async def test_pending_items_carry_the_key_each_content_type_needs() -> None:
|
||||
"""推荐方案按 `content_id` 寻址、方案书按 `goal_no` —— 两者都要给全。"""
|
||||
app = create_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://test", timeout=30
|
||||
) as client:
|
||||
admin = await _token(client, "admin_t", "88888888")
|
||||
response = await client.get(PENDING_PATH, headers={"Authorization": f"Bearer {admin}"})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
for item in response.json()["data"]:
|
||||
assert item["content_id"], "每条待审内容都必须有 content_id"
|
||||
assert item["content_type"] in {BOOK_TYPE, RECOMMENDATION_TYPE}
|
||||
assert item["review_status"] in {"pending", "pending_review"}
|
||||
if item["content_type"] == BOOK_TYPE:
|
||||
# 方案书的审核/发布端点(AD006/AD007)按 goal_no 寻址,缺了就没法调
|
||||
assert item["goal_no"], (
|
||||
f"方案书 content_id={item['content_id']} 没带 goal_no,"
|
||||
"管理员拿到列表也调不动审核端点"
|
||||
)
|
||||
else:
|
||||
assert item["goal_no"] is None, "推荐方案不该有 goal_no"
|
||||
@@ -114,3 +114,62 @@ async def test_activation_state_machine_feeds_runtime_intent_config() -> None:
|
||||
{"agent": agent_type},
|
||||
)
|
||||
await session.execute(text("DELETE FROM sys_user WHERE id=:id"), {"id": actor})
|
||||
|
||||
|
||||
async def test_activating_one_intent_does_not_archive_sibling_intents() -> None:
|
||||
"""激活一个意图**不得**归档同 Agent 的其他意图。
|
||||
|
||||
唯一键是生成列 `active_key = concat(agent_type, ':', intent_code)`,
|
||||
即同一 `agent_type` 下**不同意图码本就允许并存**(风控的 4 个意图就是并存的)。
|
||||
|
||||
这里曾经按 `agent_type` 过滤旧 active 版本去归档,于是激活 `general` 会把
|
||||
`risk_overview` / `risk_search` / `risk_evidence` 一并归档:风控运行期只剩 1 条
|
||||
active 意图,问"查看当前风险概览"被分到 `general`,**且没有任何报错**。
|
||||
|
||||
上一个用例恰好盖不住这个缺陷 —— 它建的第二个意图始终停留在 draft、从未激活过。
|
||||
"""
|
||||
agent_type = f"it_intent_multi_{uuid.uuid4().hex[:12]}"
|
||||
actor = uuid.uuid4().int % 10**12 + 10**15
|
||||
async with SessionFactory() as session, session.begin():
|
||||
await session.execute(text("""
|
||||
INSERT INTO sys_user
|
||||
(id,user_no,username,password_hash,user_type,professional_investor_status,
|
||||
fund_account_status,status,created_at,updated_at)
|
||||
VALUES (:id,:name,:name,'test-only','员工','未申请','未开户','正常',
|
||||
UTC_TIMESTAMP(),UTC_TIMESTAMP())
|
||||
"""), {"id": actor, "name": f"it-intent-multi-{actor}"})
|
||||
try:
|
||||
context = RequestContext(
|
||||
user_id=str(actor), trace_id="it-intent-multi", roles=("admin",)
|
||||
)
|
||||
service = AdminService()
|
||||
# 两个**不同意图码**依次走完 draft -> approved -> active
|
||||
for code in ("alpha", "beta"):
|
||||
async with SessionFactory() as session:
|
||||
repo = PlatformRepository(session)
|
||||
created = await repo.create("agent_intent_config", {
|
||||
"agent_type": agent_type, "intent_code": code,
|
||||
"intent_name": f"{code} 意图", "description": f"{code} 描述",
|
||||
"examples": [f"{code} 示例"],
|
||||
"confidence_threshold": Decimal("0.6500"), "version": 1,
|
||||
"created_by": actor,
|
||||
})
|
||||
approved = await service._transition(
|
||||
repo, "agent-intent-configs", created, "reviews",
|
||||
{"decision": "approved"}, context
|
||||
)
|
||||
await service._transition(
|
||||
repo, "agent-intent-configs", approved, "activations", {}, context
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# 关键断言:激活 beta 之后 alpha **仍然 active**,两个都要能被运行期读到
|
||||
entries = await load_active_intent_configs(agent_type)
|
||||
assert sorted(entry.intent_code for entry in entries) == ["alpha", "beta"]
|
||||
finally:
|
||||
async with SessionFactory() as session, session.begin():
|
||||
await session.execute(
|
||||
text("DELETE FROM agent_intent_config WHERE agent_type=:agent"),
|
||||
{"agent": agent_type},
|
||||
)
|
||||
await session.execute(text("DELETE FROM sys_user WHERE id=:id"), {"id": actor})
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""真实 MySQL:访客令牌必须能读到公开产品列表。
|
||||
|
||||
## 为什么单独守这一条
|
||||
|
||||
访客令牌**不带任何权限码**(`app/core/security.py` 只给它 `roles=("visitor",)`),
|
||||
P001 靠的是"要求令牌但不校验权限"的口径。若有人照管理面接口的样子给它加上权限码,
|
||||
访客产品页(首页推荐、产品列表、产品详情)会**整体 401**,
|
||||
而前端只会显示"数据暂时不可用",极难联想到是权限口径问题。
|
||||
|
||||
本文件同时守住**载荷边界**:公开端点不得混入任何账户/客户字段。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
#: 公开端点绝不能出现的字段(账户与客户数据)
|
||||
FORBIDDEN_FIELDS = {
|
||||
"customer_id", "account_id", "trade_account", "available_cash",
|
||||
"total_asset", "total_quantity", "average_cost", "cost_amount",
|
||||
}
|
||||
|
||||
#: 前端三个访客页面直接消费的字段
|
||||
REQUIRED_FIELDS = {
|
||||
"product_code", "product_name", "exchange_code", "product_category", "risk_level",
|
||||
"fund_manager", "current_nav", "current_nav_at", "status", "lot_size", "price_tick",
|
||||
"management_fee_rate", "custodian_fee_rate", "latest_close", "latest_trade_date",
|
||||
"quote_source", "change_pct",
|
||||
}
|
||||
|
||||
|
||||
async def test_visitor_token_can_read_listed_products() -> None:
|
||||
app = create_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://test", timeout=30
|
||||
) as client:
|
||||
issued = await client.post("/api/v1/visitor-tokens")
|
||||
# 该端点按创建语义返回 201(本平台创建类端点的统一口径)
|
||||
assert issued.status_code in (200, 201), issued.text
|
||||
# 访客令牌端点是 `raw` 形状:令牌直接在顶层,没有 data 信封
|
||||
token = issued.json()["access_token"]
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/products", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()["data"]
|
||||
products = data["products"]
|
||||
|
||||
assert products, "产品列表为空:访客页面会显示不出任何基金"
|
||||
assert data["count"] == len(products)
|
||||
|
||||
# 只暴露在售产品
|
||||
assert all(item["status"] == "上市" for item in products)
|
||||
|
||||
first = products[0]
|
||||
assert REQUIRED_FIELDS <= set(first), f"缺字段:{REQUIRED_FIELDS - set(first)}"
|
||||
assert not (FORBIDDEN_FIELDS & set(first)), "公开端点混入了账户/客户字段"
|
||||
|
||||
# change_pct 只能是数字或 None。None 表示行情不足两个交易日、算不出涨跌,
|
||||
# **不是 0** —— 前端对 None 显示"暂无",对 0 会显示 "+0.00%"(等于说今天平盘)。
|
||||
for item in products:
|
||||
assert item["change_pct"] is None or isinstance(item["change_pct"], (int, float)), (
|
||||
f"{item['product_code']} 的 change_pct 类型异常:{item['change_pct']!r}"
|
||||
)
|
||||
|
||||
# 价格类字段一律是字符串(与既有接口口径一致)
|
||||
assert isinstance(first["current_nav"], str)
|
||||
|
||||
|
||||
async def test_visitor_token_can_read_nav_history() -> None:
|
||||
"""P002:访客能取到历史净值序列(详情页走势图的数据源)。
|
||||
|
||||
`fin_nav_history` 为空时返回 `count=0` 与空数组 —— 这是**合法响应**,
|
||||
不是错误:前端据此显示"尚未接入",而不得回退到编造的曲线。
|
||||
"""
|
||||
app = create_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://test", timeout=30
|
||||
) as client:
|
||||
issued = await client.post("/api/v1/visitor-tokens")
|
||||
token = issued.json()["access_token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
listed = await client.get("/api/v1/products", headers=auth)
|
||||
products = listed.json()["data"]["products"]
|
||||
assert products, "产品库为空,净值用例无从下手"
|
||||
code = products[0]["product_code"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/products/{code}/nav-history", params={"days": 30}, headers=auth
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()["data"]
|
||||
assert data["product_code"] == code
|
||||
|
||||
points = data["points"]
|
||||
assert data["count"] == len(points)
|
||||
if not points:
|
||||
# 还没跑 `tools/sync_nav_history.py` —— 允许,但必须是"干净的空"
|
||||
return
|
||||
assert points[0]["nav_date"] <= points[-1]["nav_date"], "净值序列必须按日期升序"
|
||||
assert all(isinstance(item["nav"], str) for item in points)
|
||||
assert len(points) <= 30
|
||||
|
||||
|
||||
async def test_nav_history_returns_404_for_unknown_product() -> None:
|
||||
"""不存在的产品必须是 404,而不是空数组 —— 否则前端分不清"没有数据"和"没有这只"。"""
|
||||
app = create_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://test", timeout=30
|
||||
) as client:
|
||||
issued = await client.post("/api/v1/visitor-tokens")
|
||||
token = issued.json()["access_token"]
|
||||
response = await client.get(
|
||||
"/api/v1/products/999999/nav-history",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 404, response.text
|
||||
Reference in New Issue
Block a user