- Added `list_nav_history` method in `CoreReadOnlyRepository` to retrieve historical NAV data for products over a specified number of days. - Introduced `get_nav_history` endpoint in `product_service` to return historical NAV sequences, improving product data accessibility. - Updated `products.py` to include new API endpoint for fetching NAV history, ensuring compliance with access control. - Enhanced `ready.py` to include a health check for the new functionality, ensuring system readiness. - Updated documentation to reflect the new testing baseline of 833 passed tests, indicating improved stability and functionality across the application. This update significantly enhances the product API by providing access to historical NAV data, improving user insights into product performance.
266 lines
9.1 KiB
Python
266 lines
9.1 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"}
|
||
COMPLIANCE = {"X-Debug-Role": "compliance", "X-Debug-Actor": "STAFF-40001"}
|
||
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), "
|
||
"('PROD-001', date('now','-1 day'), 1.24, -0.2), "
|
||
"('PROD-001', date('now','-30 day'), 1.10, 0.5)"
|
||
)
|
||
)
|
||
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_nav_history_customer_ok(client):
|
||
r = client.get("/api/products/PROD-001/nav/history?days=365", headers=CUST_A)
|
||
assert r.status_code == 200
|
||
data = r.json()["data"]
|
||
assert data["product_id"] == "PROD-001"
|
||
assert data["count"] >= 3
|
||
assert len(data["items"]) >= 3
|
||
dates = [it["nav_date"] for it in data["items"]]
|
||
assert dates == sorted(dates)
|
||
|
||
|
||
def test_nav_history_analyst_ok(client):
|
||
r = client.get("/api/products/PROD-001/nav/history", headers=ANALYST)
|
||
assert r.status_code == 200
|
||
|
||
|
||
def test_nav_history_compliance_forbidden(client):
|
||
r = client.get("/api/products/PROD-001/nav/history", headers=COMPLIANCE)
|
||
assert r.status_code == 403
|
||
assert r.json()["error_code"] == "AUTH_403_SCOPE"
|
||
|
||
|
||
def test_nav_history_not_found(client):
|
||
r = client.get("/api/products/PROD-404/nav/history", headers=CUST_A)
|
||
assert r.status_code == 404
|
||
|
||
|
||
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**"
|