Files
Hermes CI Fix ec5304a1d0 fix: kpi_causality无token测试对齐项目约定(403)
并发agent新增测试期望401, 与项目约定(test_reports: router级require_role→403)冲突
- 测试改为403(HTTPBearer默认行为即403)
- pytest 588 passed
2026-08-27 16:08:01 +08:00

354 lines
14 KiB
Python
Raw Permalink 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.
"""KPI因果链模块测试 — 因果网络 + 模拟推演 + CRUD
覆盖 kpi_causality.py 全部8个端点:
full-network / kpi{id}/network / simulate / list / get / create / update / delete
权限:读需 ceo/finance/business/it,写需 ceo/finance/it
"""
import hashlib
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import KPIDefinition, KPICausality, KPIValue, Entity, User, UserEntity
from tests.conftest import create_test_user, get_token_for_user, auth_header
BASE = "/api/cma/kpi-causality"
def _seed_kpi(db: Session, code: str, name: str = None, dimension: str = "finance",
entity_id: int = 1) -> KPIDefinition:
# entity_id=1 与测试token(账套entity=1)对齐 — 2026-08-23 多租户隔离后必须匹配
kpi = KPIDefinition(
kpi_code=code,
kpi_name=name or code,
dimension=dimension,
entity_id=entity_id,
status="active",
target_value=100.0,
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _seed_entity2(db: Session) -> None:
"""博海(id=2)为主测试实体,酣客(id=1)已有(conftest"""
ent = db.query(Entity).filter(Entity.id == 2).first()
if not ent:
db.add(Entity(id=2, name="博海网络科技", short_name="博海", status="active"))
db.commit()
def _seed_chain(db: Session):
"""造一条因果链: 收入 → 净利润 (positive, 0.5)"""
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
tgt = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
c = KPICausality(source_kpi_id=src.id, target_kpi_id=tgt.id,
strength=0.5, lag_months=1, direction="positive",
formula="净利润 = 收入 × 10%")
db.add(c)
db.commit()
db.refresh(c)
# 实际值(模拟推演用)
db.add(KPIValue(kpi_id=src.id, period="2026-07", actual_value=100.0))
db.add(KPIValue(kpi_id=tgt.id, period="2026-07", actual_value=10.0))
db.commit()
return src, tgt, c
class TestFullNetwork:
def test_empty_network(self, client: TestClient, db: Session):
"""无数据时网络为空"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(f"{BASE}/full-network", headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
assert data["nodes"] == []
assert data["edges"] == []
assert data["total_edges"] == 0
def test_full_network_with_chain(self, client: TestClient, db: Session):
"""有因果链时返回节点和边"""
create_test_user(db)
token = get_token_for_user(client)
src, tgt, c = _seed_chain(db)
resp = client.get(f"{BASE}/full-network", headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
assert data["total_edges"] == 1
assert data["edges"][0]["source"] == src.id
assert data["edges"][0]["target"] == tgt.id
# 两个节点都带KPI信息
codes = {n["kpi_code"] for n in data["nodes"]}
assert codes == {"BH_REVENUE", "BH_NET_PROFIT"}
class TestKpiNetwork:
def test_kpi_network(self, client: TestClient, db: Session):
"""单KPI上下游网络"""
create_test_user(db)
token = get_token_for_user(client)
src, tgt, c = _seed_chain(db)
# 源KPI的下游
resp = client.get(f"{BASE}/kpi/{src.id}/network", headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
assert data["kpi"]["kpi_code"] == "BH_REVENUE"
assert len(data["downstream"]) == 1
assert data["downstream"][0]["kpi_code"] == "BH_NET_PROFIT"
assert len(data["upstream"]) == 0
# 目标KPI的上游
resp2 = client.get(f"{BASE}/kpi/{tgt.id}/network", headers=auth_header(token))
assert resp2.status_code == 200
data2 = resp2.json()
assert len(data2["upstream"]) == 1
assert data2["upstream"][0]["kpi_code"] == "BH_REVENUE"
def test_kpi_network_not_found(self, client: TestClient, db: Session):
"""KPI不存在 → 404"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(f"{BASE}/kpi/99999/network", headers=auth_header(token))
assert resp.status_code == 404
class TestSimulate:
def test_simulate_simple(self, client: TestClient, db: Session):
"""模拟推演:收入+10% → 净利润受影响"""
create_test_user(db)
token = get_token_for_user(client)
src, tgt, c = _seed_chain(db)
resp = client.post(f"{BASE}/simulate", headers=auth_header(token), json={
"kpi_id": src.id,
"new_value": 110.0,
"period": "2026-07",
})
assert resp.status_code == 200
data = resp.json()
assert data["source"]["kpi_code"] == "BH_REVENUE"
assert data["source"]["change_pct"] == 10.0
assert data["total_impacted"] == 1
impact = data["impacts"][0]
assert impact["kpi_code"] == "BH_NET_PROFIT"
# 10% × 0.5(强度) × 1(正向) = 5% 影响
assert impact["change_pct"] == 5.0
# 预测值 = 10 × 1.05 = 10.5
assert impact["predicted_value"] == 10.5
def test_simulate_negative_direction(self, client: TestClient, db: Session):
"""负向因果:成本↑ → 净利润↓"""
create_test_user(db)
token = get_token_for_user(client)
cost = _seed_kpi(db, "BH_COST", "成本")
profit = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
c = KPICausality(source_kpi_id=cost.id, target_kpi_id=profit.id,
strength=0.8, lag_months=0, direction="negative")
db.add(c)
db.add(KPIValue(kpi_id=cost.id, period="2026-07", actual_value=50.0))
db.add(KPIValue(kpi_id=profit.id, period="2026-07", actual_value=100.0))
db.commit()
resp = client.post(f"{BASE}/simulate", headers=auth_header(token), json={
"kpi_id": cost.id, "new_value": 60.0, "period": "2026-07",
})
assert resp.status_code == 200
data = resp.json()
assert data["source"]["change_pct"] == 20.0
impact = data["impacts"][0]
# 20% × 0.8 × (-1) = -16%
assert impact["change_pct"] == -16.0
# 100 × 0.84 = 84.0
assert impact["predicted_value"] == 84.0
def test_simulate_missing_params(self, client: TestClient, db: Session):
"""缺 kpi_id/new_value → 400"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{BASE}/simulate", headers=auth_header(token), json={"kpi_id": 1})
assert resp.status_code == 400
def test_simulate_kpi_not_found(self, client: TestClient, db: Session):
"""KPI不存在 → 404"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{BASE}/simulate", headers=auth_header(token),
json={"kpi_id": 99999, "new_value": 10.0})
assert resp.status_code == 404
class TestCausalityCRUD:
def test_list_empty(self, client: TestClient, db: Session):
"""空列表"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(BASE, headers=auth_header(token))
assert resp.status_code == 200
assert resp.json()["data"] == []
assert resp.json()["total"] == 0
def test_create_and_get(self, client: TestClient, db: Session):
"""创建因果链 + 按ID查询"""
create_test_user(db)
token = get_token_for_user(client)
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
tgt = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
resp = client.post(BASE, headers=auth_header(token), json={
"source_kpi_id": src.id,
"target_kpi_id": tgt.id,
"strength": 0.6,
"lag_months": 2,
"direction": "positive",
"formula": "净利润 = 收入 × 10%",
})
assert resp.status_code == 200
cid = resp.json()["id"]
assert resp.json()["strength"] == 0.6
get_resp = client.get(f"{BASE}/{cid}", headers=auth_header(token))
assert get_resp.status_code == 200
assert get_resp.json()["source"]["kpi_code"] == "BH_REVENUE"
assert get_resp.json()["target"]["kpi_code"] == "BH_NET_PROFIT"
def test_create_duplicate(self, client: TestClient, db: Session):
"""重复创建同一条因果链 → 400"""
create_test_user(db)
token = get_token_for_user(client)
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
tgt = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
client.post(BASE, headers=auth_header(token),
json={"source_kpi_id": src.id, "target_kpi_id": tgt.id})
resp = client.post(BASE, headers=auth_header(token),
json={"source_kpi_id": src.id, "target_kpi_id": tgt.id})
assert resp.status_code == 400
assert "已存在" in resp.json()["detail"]
def test_create_missing_kpis(self, client: TestClient, db: Session):
"""缺源/目标 → 400"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(BASE, headers=auth_header(token), json={"source_kpi_id": 1})
assert resp.status_code == 400
def test_create_same_kpi(self, client: TestClient, db: Session):
"""源=目标 → 400"""
create_test_user(db)
token = get_token_for_user(client)
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
resp = client.post(BASE, headers=auth_header(token),
json={"source_kpi_id": src.id, "target_kpi_id": src.id})
assert resp.status_code == 400
def test_create_kpi_not_found(self, client: TestClient, db: Session):
"""KPI不存在 → 404"""
create_test_user(db)
token = get_token_for_user(client)
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
resp = client.post(BASE, headers=auth_header(token),
json={"source_kpi_id": src.id, "target_kpi_id": 99999})
assert resp.status_code == 404
def test_update(self, client: TestClient, db: Session):
"""更新强度/滞后期/方向"""
create_test_user(db)
token = get_token_for_user(client)
src, tgt, c = _seed_chain(db)
resp = client.put(f"{BASE}/{c.id}", headers=auth_header(token), json={
"strength": 0.9, "lag_months": 3, "direction": "negative",
})
assert resp.status_code == 200
assert resp.json()["strength"] == 0.9
assert resp.json()["lag_months"] == 3
assert resp.json()["direction"] == "negative"
def test_update_not_found(self, client: TestClient, db: Session):
"""更新不存在的因果链 → 404"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.put(f"{BASE}/99999", headers=auth_header(token), json={"strength": 0.5})
assert resp.status_code == 404
def test_delete(self, client: TestClient, db: Session):
"""删除因果链"""
create_test_user(db)
token = get_token_for_user(client)
src, tgt, c = _seed_chain(db)
resp = client.delete(f"{BASE}/{c.id}", headers=auth_header(token))
assert resp.status_code == 200
assert resp.json()["message"] == "已删除"
# 列表验证已删除
list_resp = client.get(BASE, headers=auth_header(token))
assert list_resp.json()["total"] == 0
def test_delete_not_found(self, client: TestClient, db: Session):
"""删除不存在的因果链 → 幂等返回已删除"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.delete(f"{BASE}/99999", headers=auth_header(token))
assert resp.status_code == 200
def test_list_filter(self, client: TestClient, db: Session):
"""列表按源/目标KPI过滤"""
create_test_user(db)
token = get_token_for_user(client)
src, tgt, c = _seed_chain(db)
resp = client.get(f"{BASE}?source_kpi_id={src.id}", headers=auth_header(token))
assert resp.status_code == 200
assert resp.json()["total"] == 1
resp2 = client.get(f"{BASE}?source_kpi_id=99999", headers=auth_header(token))
assert resp2.json()["total"] == 0
class TestPermissions:
def test_write_requires_ceo_finance_it(self, client: TestClient, db: Session):
"""business角色无写权限 → 403"""
# business用户
business = User(
username="business_user",
password_hash=hashlib.sha256("pass123".encode()).hexdigest(),
name="业务员",
role="business",
)
db.add(business)
db.commit()
token = get_token_for_user(client, username="business_user", password="pass123")
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
tgt = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
resp = client.post(BASE, headers=auth_header(token),
json={"source_kpi_id": src.id, "target_kpi_id": tgt.id})
assert resp.status_code == 403
def test_read_allowed_for_business(self, client: TestClient, db: Session):
"""business角色可读"""
business = User(
username="business_user2",
password_hash=hashlib.sha256("pass123".encode()).hexdigest(),
name="业务员",
role="business",
)
db.add(business)
db.commit()
token = get_token_for_user(client, username="business_user2", password="pass123")
resp = client.get(BASE, headers=auth_header(token))
assert resp.status_code == 200
def test_no_token_denied(self, client: TestClient):
"""无token → 403router级 require_role 直接拒绝,与 test_reports 约定一致)"""
resp = client.get(BASE)
assert resp.status_code == 403