"""
BSC·OKR·KPI 三位一体 — 集成测试
覆盖文档中缺失的边缘场景:边界值、并发、权限、归档等
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from datetime import datetime, timedelta, timezone
from tests.conftest import (
create_test_user, get_token_for_user, auth_header,
create_test_kpi, create_test_map,
)
from app.models import Objective, ActionPlan, KPIDefinition, BscLayerConfig
# ============================================================
# 测试数据工厂
# ============================================================
def create_test_objective(db: Session, **kwargs) -> Objective:
defaults = {
"title": "测试OKR目标",
"quarter": "2026Q3",
"dimension": "finance",
"owner": "测试管理员",
"status": "active",
"progress": 0,
}
defaults.update(kwargs)
obj = Objective(**defaults)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def create_test_kr(db: Session, kpi_id: int, objective_id: int, **kwargs) -> ActionPlan:
defaults = {
"kpi_id": kpi_id,
"objective_id": objective_id,
"title": "测试KR",
"assignee": "张三",
"priority": "medium",
"status": "pending",
"progress": 0,
"due_date": datetime(2026, 9, 15, tzinfo=timezone.utc), # Q3范围内
"created_by": "testadmin",
}
defaults.update(kwargs)
plan = ActionPlan(**defaults)
db.add(plan)
db.commit()
db.refresh(plan)
return plan
# ============================================================
# TC01: 边界—KR截止日期设在过去
# ============================================================
class TestKRDeadlineBoundaries:
"""KR截止日期边界测试"""
def test_kr_due_date_in_past(self, client: TestClient, db: Session):
"""TC01: KR截止日期可以设在过去吗?"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
resp = client.post(
"/api/cma/action-plans",
headers=auth_header(token),
json={
"title": "已过期的KR",
"kpi_id": kpi.id,
"due_date": yesterday,
},
)
# 系统应该允许(业务上允许回顾性计划),但最好有个告警
assert resp.status_code == 200, f"截止日期过去时拒绝:{resp.json()}"
data = resp.json()
assert data["due_date"] is not None
# 验证过期状态
due = datetime.fromisoformat(data["due_date"].replace("Z", "+00:00") if data["due_date"].endswith("Z") else data["due_date"])
now = datetime.now(timezone.utc).replace(tzinfo=None)
due_naive = due.replace(tzinfo=None)
assert due_naive < now, "应存储为过去日期"
def test_kr_due_date_far_future(self, client: TestClient, db: Session):
"""TC02: KR截止日期设在10年后"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
far_future = (datetime.now(timezone.utc) + timedelta(days=3650)).isoformat()
resp = client.post(
"/api/cma/action-plans",
headers=auth_header(token),
json={
"title": "超远期KR",
"kpi_id": kpi.id,
"due_date": far_future,
},
)
assert resp.status_code == 200, f"超远期日期被拒绝:{resp.json()}"
def test_kr_due_date_year_2025(self, client: TestClient, db: Session):
"""TC03: 用户手滑设了2025年的日期(已过时)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
wrong_year = "2025-01-01T00:00:00"
resp = client.post(
"/api/cma/action-plans",
headers=auth_header(token),
json={
"title": "错误年份KR",
"kpi_id": kpi.id,
"due_date": wrong_year,
},
)
# 系统当前没有校验年份一致性,这可能是隐患
assert resp.status_code == 200
data = resp.json()
due = datetime.fromisoformat(data["due_date"])
assert due.year == 2025, "尽管不合逻辑,系统存储了错误年份"
# ============================================================
# TC02: KPI删除/禁用后,引用的KR怎么办
# ============================================================
class TestKPIReferencedByKR:
"""KPI被KR引用后的删除行为"""
def test_delete_kpi_referenced_by_kr(self, client: TestClient, db: Session):
"""TC04: 删除被KR引用的KPI"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="F_REF_001")
obj = create_test_objective(db)
kr = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id)
# 删除KPI
resp = client.delete(
f"/api/cma/kpis/{kpi.id}",
headers=auth_header(token),
)
assert resp.status_code == 200
# KR还在吗?引用的KPI状态变了?
kr_resp = client.get(
f"/api/cma/action-plans",
headers=auth_header(token),
)
assert kr_resp.status_code == 200
plans = kr_resp.json()["data"]
matching = [p for p in plans if p["id"] == kr.id]
assert len(matching) == 1, "KR应该在KPI删除后仍然存在"
assert matching[0]["kpi_id"] == kpi.id
# KPI状态变为disabled
kpi_resp = client.get(
f"/api/cma/kpis/{kpi.id}",
headers=auth_header(token),
)
assert kpi_resp.status_code == 200
assert kpi_resp.json()["status"] != "active"
def test_restore_kpi_updates_kr_context(self, client: TestClient, db: Session):
"""TC05: 恢复已删除KPI后KR自动恢复"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="F_RESTORE_001")
obj = create_test_objective(db)
kr = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id)
# 删除
client.delete(f"/api/cma/kpis/{kpi.id}", headers=auth_header(token))
# 恢复
resp = client.put(
f"/api/cma/kpis/{kpi.id}/restore",
headers=auth_header(token),
)
assert resp.status_code == 200
# 验证KPI已恢复
kpi_resp = client.get(
f"/api/cma/kpis/{kpi.id}",
headers=auth_header(token),
)
assert kpi_resp.status_code == 200
assert kpi_resp.json()["status"] == "active", "恢复后应该是active"
# ============================================================
# TC03: KR创建时目标值与当前值相同 / 极端进度
# ============================================================
class TestKREdgeProgress:
"""KR进度极端值测试"""
def test_kr_initial_progress_already_100(self, client: TestClient, db: Session):
"""TC06: KR一开始进度就是100%(完成了才创建?)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
obj = create_test_objective(db)
resp = client.post(
"/api/cma/action-plans",
headers=auth_header(token),
json={
"title": "已完成但才创建",
"kpi_id": kpi.id,
"objective_id": obj.id,
"progress": 100,
"status": "completed",
},
)
# 注意:create接口没有透传progress/status参数,创建时固定为0/pending
assert resp.status_code == 200
data = resp.json()
assert data["progress"] == 0, "创建时progress应初始化为0"
assert data["status"] == "pending", "创建时status应为pending"
def test_update_progress_boundaries(self, client: TestClient, db: Session):
"""TC07: 进度值负数/超大数被截断到0-100"""
user = create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
plan = create_test_kr(db, kpi_id=kpi.id, objective_id=1)
# 负数
resp1 = client.put(
f"/api/cma/action-plans/{plan.id}",
headers=auth_header(token),
json={"progress": -50},
)
assert resp1.status_code == 200
assert resp1.json()["progress"] == 0, "负数应截断为0"
# 超大
resp2 = client.put(
f"/api/cma/action-plans/{plan.id}",
headers=auth_header(token),
json={"progress": 999},
)
assert resp2.status_code == 200
assert resp2.json()["progress"] == 100, "超100应截断为100"
# 正常值
resp3 = client.put(
f"/api/cma/action-plans/{plan.id}",
headers=auth_header(token),
json={"progress": 50},
)
assert resp3.status_code == 200
assert resp3.json()["progress"] == 50
def test_kr_with_zero_kpi_target(self, client: TestClient, db: Session):
"""TC08: KPI目标值为0时,KR进度计算不崩溃"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="F_ZERO_001", target_value=0)
obj = create_test_objective(db)
kr = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id)
# 验证KPI评分(内部有除零风险)
score_resp = client.get(
f"/api/cma/kpis/score?entity_id=1",
headers=auth_header(token),
)
assert score_resp.status_code == 200
scores = score_resp.json()["kpis"]
matching = [s for s in scores if s["kpi_code"] == "F_ZERO_001"]
if matching:
# 目标值为0时,评分应为None(不崩溃)
assert matching[0]["score"] is None, "目标0时评分应为空"
# ============================================================
# TC04: OKR完整生命周期
# ============================================================
class TestOKRFullLifecycle:
"""OKR从创建→添加KR→更新→归档的完整生命周期"""
def test_create_objective(self, client: TestClient, db: Session):
"""TC09: 创建OKR目标"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(
f"/api/cma/okr",
headers=auth_header(token),
json={"title": "2026Q3优化成本", "quarter": "2026Q3", "dimension": "finance", "owner": "任总"},
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["title"] == "2026Q3优化成本"
assert "id" in data
def test_add_kr_to_objective(self, client: TestClient, db: Session):
"""TC10: 给OKR添加KR(改善行动计划)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="C_CHANNEL_REBATE", kpi_name="渠补率")
obj = create_test_objective(db, title="优化成本结构")
# 截止日期必须在Q3范围内(Q3=7/1~9/30)
from datetime import timezone
due = datetime(2026, 9, 15, tzinfo=timezone.utc).isoformat()
resp = client.post(
"/api/cma/action-plans",
headers=auth_header(token),
json={
"title": "渠补率从82.8%降到75%",
"kpi_id": kpi.id,
"objective_id": obj.id,
"assignee": "任总",
"priority": "high",
"due_date": due,
},
)
assert resp.status_code == 200
kr_data = resp.json()
assert kr_data["kpi_id"] == kpi.id
assert kr_data["status"] == "pending"
def test_get_objective_with_krs(self, client: TestClient, db: Session):
"""TC11: 查看OKR详情包含关联KR"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="C_CHANNEL_REBATE")
obj = create_test_objective(db, title="优化成本结构")
kr = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
title="渠补率降到75%")
resp = client.get(
f"/api/cma/okr/{obj.id}",
headers=auth_header(token),
)
assert resp.status_code == 200
data = resp.json()
assert data["objective"]["title"] == "优化成本结构"
assert len(data["key_results"]) >= 1
kr_found = any(k["id"] == kr.id for k in data["key_results"])
assert kr_found, "KR应出现在OKR详情中"
def test_objective_progress_from_krs(self, client: TestClient, db: Session):
"""TC12: OKR进度随KR进度自动计算"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
obj = create_test_objective(db)
kr1 = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
title="KR1", progress=80)
kr2 = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
title="KR2", progress=40)
# 触发progress重算
resp = client.patch(
f"/api/cma/okr/{obj.id}",
headers=auth_header(token),
)
assert resp.status_code == 200
data = resp.json()
# progress = (80 + 40) // 2 = 60
assert data["progress"] == 60, f"OKR进度应为60,实际为{data['progress']}"
def test_quarter_end_archive(self, client: TestClient, db: Session):
"""TC13: 季度结束后归档 — KR应能标记为completed/failed"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
obj = create_test_objective(db, quarter="2026Q3")
kr = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
title="Q3关键结果")
# 完成KR
resp = client.put(
f"/api/cma/action-plans/{kr.id}",
headers=auth_header(token),
json={"status": "completed", "progress": 100},
)
assert resp.status_code == 200
assert resp.json()["status"] == "completed"
# 完成Objective
resp2 = client.patch(
f"/api/cma/okr/{obj.id}",
headers=auth_header(token),
)
assert resp2.status_code == 200
# 验证季度过滤
resp3 = client.get(
f"/api/cma/okr?quarter=2026Q3",
headers=auth_header(token),
)
assert resp3.status_code == 200
items = resp3.json()["items"]
obj_found = any(o["id"] == obj.id for o in items)
assert obj_found, "归档后的OKR在季度过滤中可查"
# ============================================================
# TC05: 权限测试 — 不同角色的访问控制
# ============================================================
class TestRolePermissions:
"""不同角色的权限边界"""
def test_unauthorized_access(self, client: TestClient, db: Session):
"""TC14: 未登录访问被拒绝"""
resp = client.get("/api/cma/okr")
assert resp.status_code == 403 or resp.status_code == 401
def test_business_role_cannot_create_kpi(self, client: TestClient, db: Session):
"""TC15: business角色不能创建KPI"""
create_test_user(db, username="bizuser", name="业务员", role="business")
token = get_token_for_user(client, username="bizuser")
resp = client.post(
"/api/cma/kpis",
headers=auth_header(token),
json={"kpi_code": "F_BIZ_001", "kpi_name": "业务创建", "dimension": "finance"},
)
# business角色没有写权限
assert resp.status_code == 403 or resp.status_code == 401
def test_business_can_create_action_plan(self, client: TestClient, db: Session):
"""TC16: business角色可以创建行动计划"""
create_test_user(db, username="bizuser2", name="业务员", role="business")
token = get_token_for_user(client, username="bizuser2")
kpi = create_test_kpi(db)
resp = client.post(
"/api/cma/action-plans",
headers=auth_header(token),
json={"title": "业务员计划", "kpi_id": kpi.id},
)
assert resp.status_code == 200, f"business应能创建计划:{resp.json()}"
def test_it_role_can_delete_kpi(self, client: TestClient, db: Session):
"""TC17: IT角色可以删除KPI"""
create_test_user(db, username="ituser", name="管理员", role="it")
token = get_token_for_user(client, username="ituser")
kpi = create_test_kpi(db)
resp = client.delete(
f"/api/cma/kpis/{kpi.id}",
headers=auth_header(token),
)
assert resp.status_code == 200
# ============================================================
# TC06: 搜索安全 — 特殊字符/注入
# ============================================================
class TestSearchSecurity:
"""KPI搜索安全性"""
def test_search_with_special_chars(self, client: TestClient, db: Session):
"""TC18: 搜索含特殊字符的KPI"""
create_test_user(db)
token = get_token_for_user(client)
# 创建带特殊字符的KPI
kpi = create_test_kpi(db, kpi_code="F_XSS_001",
kpi_name="")
# 搜索特殊字符
resp = client.get(
"/api/cma/kpis?keyword=