236 lines
8.0 KiB
Python
236 lines
8.0 KiB
Python
"""代销平台 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**"
|