Files
group_xinghuo_jinrong/tests/test_platform_api.py
T
zhanghongyu_0626 ef56c56435 feat(api): 实现代销平台 API v0.1,包括客户、产品、理财师、合规及员工接口
- 新增多个 API 路由:`/api/customers`, `/api/products`, `/api/advisors`, `/api/compliance`, `/api/staff`,支持客户信息、产品详情、理财师客户列表、合规判定及员工上下文查询。
- 引入平台服务层,封装核心只读操作,支持数据脱敏功能。
- 更新依赖注入,确保各 API 路由的权限控制与数据访问一致性。
- 添加相应的单元测试,确保新接口的功能完整性与稳定性。

此更新为代销平台提供了基础的 REST API 支持,增强了系统的可扩展性与可维护性。
2026-09-08 21:01:07 +08:00

236 lines
8.0 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.
"""代销平台 API v0.1(customers/products/advisors/staff/compliance)。"""
from __future__ import annotations
import json
from datetime import datetime, timedelta
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from _ddl import create_sqlite_engine, seed_suitability_matrix
from app.config.settings import settings
from app.main import app
from app.repository.risk_repository import RiskRepository
from app.service.risk import redis_gateway
class FakeGateway:
def publish(self, channel, payload):
pass
def delete(self, *keys):
pass
CUST_A = {"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1001"}
CUST_B = {"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1002"}
ADVISOR = {"X-Debug-Role": "advisor", "X-Debug-Actor": "STAFF-10086"}
ANALYST = {"X-Debug-Role": "analyst", "X-Debug-Actor": "STAFF-20001"}
OTHER_ADV = {"X-Debug-Role": "advisor", "X-Debug-Actor": "STAFF-99999"}
def _seed_core(engine) -> None:
future = (datetime.now() + timedelta(days=180)).isoformat(sep=" ")
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, age, is_active) "
"VALUES ('CUST-1001','CustomerA',35,1), ('CUST-1002','CustomerB',40,1)"
)
)
conn.execute(
text(
"INSERT INTO core_customer_risk (customer_id, risk_code, expires_at, investor_category) "
"VALUES ('CUST-1001','C2',:exp,'ordinary'), ('CUST-1002','C3',:exp,'ordinary')"
),
{"exp": future},
)
conn.execute(
text(
"INSERT INTO core_customer_advisor (advisor_id, customer_id, rel_status) "
"VALUES ('STAFF-10086','CUST-1001','active')"
)
)
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code, is_open) "
"VALUES ('PROD-001','稳健债基','R2',1), ('PROD-CLOSED','已下架','R1',0)"
)
)
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, market_value, quantity) "
"VALUES ('CUST-1001','PROD-001',100000,1000)"
)
)
conn.execute(
text(
"INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount, "
"trade_status, traded_at) VALUES ('TRD-P-001','CUST-1001','PROD-001','subscribe',"
"5000,'confirmed',datetime('now','localtime'))"
)
)
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav_date, nav, daily_chg_pct) "
"VALUES ('PROD-001', date('now'), 1.25, 0.01)"
)
)
conn.execute(
text(
"INSERT INTO core_staff (staff_id, display_name, staff_type, roles, is_active) "
"VALUES ('STAFF-10086','王理财','advisor',:roles,1), "
"('STAFF-20001','分析员','analyst',:aroles,1)"
),
{"roles": json.dumps(["advisor"]), "aroles": json.dumps(["analyst"])},
)
@pytest.fixture()
def client(monkeypatch):
engine = create_sqlite_engine()
seed_suitability_matrix(engine)
_seed_core(engine)
repo = RiskRepository(engine=engine)
monkeypatch.setattr("app.utils.db.get_engine", lambda name=None: engine)
monkeypatch.setattr("app.repository.core_ro.get_engine", lambda name=None: engine)
from app.api import advisors as advisors_api
from app.api import compliance as compliance_api
from app.api import customers as customers_api
from app.api import deps as deps_mod
from app.api import staff as staff_api
monkeypatch.setattr(deps_mod, "RiskRepository", lambda: repo)
monkeypatch.setattr(customers_api, "_risk_repo", lambda: repo)
monkeypatch.setattr(advisors_api, "_risk_repo", lambda: repo)
monkeypatch.setattr(staff_api, "_risk_repo", lambda: repo)
monkeypatch.setattr(compliance_api, "_risk_repo", lambda: repo)
with TestClient(app) as c:
monkeypatch.setattr(redis_gateway, "_gateway", FakeGateway())
yield c
engine.dispose()
def test_get_customer_owner_ok(client):
r = client.get("/api/customers/CUST-1001", headers=CUST_A)
assert r.status_code == 200
body = r.json()
assert body["code"] == 0
assert body["data"]["customer_id"] == "CUST-1001"
assert body["data"]["display_name"] == "CustomerA"
def test_get_customer_other_forbidden(client):
r = client.get("/api/customers/CUST-1002", headers=CUST_A)
assert r.status_code == 403
assert r.json()["error_code"] == "AUTH_403_NOT_OWNER"
def test_get_customer_analyst_ok(client):
r = client.get("/api/customers/CUST-1002", headers=ANALYST)
assert r.status_code == 200
def test_get_customer_not_found(client):
r = client.get("/api/customers/CUST-404", headers=ANALYST)
assert r.status_code == 404
def test_list_holdings(client):
r = client.get("/api/customers/CUST-1001/holdings", headers=CUST_A)
assert r.status_code == 200
items = r.json()["data"]["items"]
assert len(items) == 1
assert items[0]["product_id"] == "PROD-001"
def test_list_trades(client):
r = client.get("/api/customers/CUST-1001/trades", headers=CUST_A)
assert r.status_code == 200
assert r.json()["data"]["total"] >= 1
def test_list_customer_products(client):
r = client.get("/api/customers/CUST-1001/products", headers=CUST_A)
assert r.status_code == 200
assert r.json()["data"]["total"] >= 1
def test_advisor_roster_self(client):
r = client.get("/api/advisors/STAFF-10086/customers", headers=ADVISOR)
assert r.status_code == 200
items = r.json()["data"]["items"]
assert len(items) == 1
assert items[0]["customer_id"] == "CUST-1001"
def test_advisor_roster_other_forbidden(client):
r = client.get("/api/advisors/STAFF-10086/customers", headers=OTHER_ADV)
assert r.status_code == 403
def test_list_products_excludes_closed(client):
r = client.get("/api/products", headers=ANALYST)
assert r.status_code == 200
ids = [i["product_id"] for i in r.json()["data"]["items"]]
assert "PROD-001" in ids
assert "PROD-CLOSED" not in ids
def test_get_product_and_nav(client):
r = client.get("/api/products/PROD-001", headers=CUST_A)
assert r.status_code == 200
assert r.json()["data"]["product_name"] == "稳健债基"
r2 = client.get("/api/products/PROD-001/nav", headers=CUST_A)
assert r2.status_code == 200
assert r2.json()["data"]["nav"] == 1.25
def test_staff_me(client):
r = client.get("/api/staff/me", headers=ADVISOR)
assert r.status_code == 200
assert r.json()["data"]["staff_id"] == "STAFF-10086"
assert "advisor" in r.json()["data"]["roles"]
def test_staff_me_customer_forbidden(client):
r = client.get("/api/staff/me", headers=CUST_A)
assert r.status_code == 403
def test_compliance_suitability_check(client):
r = client.post(
"/api/compliance/suitability-check",
headers=CUST_A,
json={"customer_id": "CUST-1001", "product_id": "PROD-001"},
)
assert r.status_code == 200
data = r.json()["data"]
assert data["is_matched"] is True
assert data["blocked"] is False
def test_platform_no_agent_type_header(client):
"""平台 JWT/debug 通道不要求 X-Agent-Type。"""
r = client.get("/api/customers/CUST-1001", headers=CUST_A)
assert r.status_code == 200
assert "X-Agent-Type" not in CUST_A
def test_desensitize_switch_off_by_default(client, monkeypatch):
monkeypatch.setattr(settings, "platform_response_desensitize", False)
r = client.get("/api/customers/CUST-1001", headers=CUST_A)
assert r.json()["data"]["display_name"] == "CustomerA"
def test_desensitize_switch_on(client, monkeypatch):
monkeypatch.setattr(settings, "platform_response_desensitize", True)
r = client.get("/api/customers/CUST-1001", headers=CUST_A)
assert r.json()["data"]["display_name"] == "C**"