"""代销平台 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**"