test: CMA自动化测试补覆盖 226→452用例, 覆盖率36%→60%

- 新增8个测试文件(bot_bridge/kpi_causality/cash/predict/reports/tax_compliance/expenses/probe_cost)
- 增强 budget/auth/users + conftest账套模式适配
- 测试驱动修复: bot_bridge导入batch_id→source_batch; cash_forecast extra空dict
- 全量: 451 passed, 1 xfailed; 报告 docs/cma-test-coverage-report.md
This commit is contained in:
Hermes CI Fix
2026-08-20 06:57:24 +08:00
parent dd53212bcc
commit a13a080381
15 changed files with 3275 additions and 6 deletions
+352
View File
@@ -0,0 +1,352 @@
"""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 = 2) -> KPIDefinition:
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 → 403"""
resp = client.get(BASE)
assert resp.status_code == 403