ci/woodpecker/push/main Pipeline failed
缺陷 cma-strategy-map-autosave-stale-overwrite 的护栏(PUT /api/cma/maps/{id} 覆写
dimensions/canvas_data 须带 base_updated_at,否则 409 fail-closed)改变了同一写通道的
契约 ⇒ 全仓清点调用点,在调用点取基准(写前读 updated_at),**不放宽护栏**:
- tests/test_map_versions.py: 新增 _base_updated_at(db, map_id) 助手;3 处覆写 dimensions 的
PUT 补基准(test_rollback_backs_up_current_and_restores_content 会直接失败;
test_rollback_again_uses_next_free_backup_version 两处此前已静默降级——PUT 被拒但用例仍绿,
即「改了内容再回滚」的前提实际未发生)。
- tests/test_kr_name_pollution_20260916.py: 2 处 KR 透传护栏用例补基准。
改前/改后全量对拍(同树 pythonpath):
baseline 22 failed / fixed2 18 failed;diff 仅剩 tests/test_doc_center_20260924.py::
TestProdContentCanary::test_prod_content_readable —— 基线树同样失败(/srv/hermes/shared/
cma-content 于 14:05 被写入 manual 分类,共享内容目录变化),与本单无关。
本单新增的 5 例在基线树全红、改后全绿。
任务: yanxue-20261012-cma-map-autosave-stale-overwrite-defect-fullstack
181 lines
8.6 KiB
Python
181 lines
8.6 KiB
Python
"""KR 名称污染(历史截断值)护栏测试 — 任务 cma-kr-name-pollution-cleanup-20260916
|
||
|
||
覆盖:
|
||
1. 判据单测(sanitize_kr_name / target_number):污染截断、有意命名、无法判定三类
|
||
2. 「JSON/模板名 → krs.title」透传点护栏:
|
||
- PUT /api/cma/maps/{id}(_sync_objectives_krs)
|
||
- PUT /api/cma/okr/{objective_id}/krs/sync(画布节点同步)
|
||
- POST /api/cma/okr-templates/{id}/apply(模板 → 新地图)
|
||
3. 阴性对照:合法命名(渠道留存率≥90 / 新客户数≥10)经同样路径不得被改
|
||
"""
|
||
import json
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models import KR, Objective, OKRTemplate, StrategicMap
|
||
from app.utils.kr_name import sanitize_kr_name, target_number
|
||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||
|
||
|
||
# ────────────────────────── 1. 判据单测 ──────────────────────────
|
||
|
||
class TestSanitizeKrName:
|
||
@pytest.mark.parametrize("name,target,expect", [
|
||
("营业收入≥2", 2500, "营业收入"), # 输入 2500 时截断成首位数字
|
||
("新客户数≥1", 5, "新客户数"),
|
||
("营业收入≥7", 780, "营业收入"),
|
||
("净利润≥6", 60, "净利润"),
|
||
("费用率≤3", 30, "费用率"),
|
||
("经营性现金流≥5", 50, "经营性现金流"),
|
||
("营业收入≥2", "2500", "营业收入"), # 目标值为字符串数字
|
||
("应收账款天数≤4", "≤45天", "应收账款天数"), # 目标值带单位文本
|
||
])
|
||
def test_polluted_tail_stripped(self, name, target, expect):
|
||
clean, changed = sanitize_kr_name(name, target)
|
||
assert changed is True
|
||
assert clean == expect
|
||
|
||
@pytest.mark.parametrize("name,target", [
|
||
("渠道留存率≥90", 90), # 数字==目标值 = 有意命名
|
||
("新客户数≥10", 10),
|
||
("A级渠道占比≥30%", 30),
|
||
("营业收入≥780", 780),
|
||
("费用率≤30", 30),
|
||
("现金折扣收益≤收入20%", 20), # 尾部不是「符号+数字」
|
||
("库存周转天数≤45天", "≤45天"),
|
||
("数据质量评分≥90", 90.0),
|
||
("剔除现金折扣后经营性净利≥0", 0),
|
||
])
|
||
def test_legal_names_untouched(self, name, target):
|
||
clean, changed = sanitize_kr_name(name, target)
|
||
assert changed is False
|
||
assert clean == name
|
||
|
||
@pytest.mark.parametrize("name,target", [
|
||
("NPS净推荐值≥60", "为正"), # 目标值无数字 → 无法判定,原样保留
|
||
("总资产周转率≥1.0", None),
|
||
("自由现金流≥0", ""),
|
||
("经营现金流为正", "为正"), # 无尾部符号+数字
|
||
])
|
||
def test_unknown_target_untouched(self, name, target):
|
||
clean, changed = sanitize_kr_name(name, target)
|
||
assert changed is False
|
||
assert clean == name
|
||
|
||
def test_empty_base_kept(self):
|
||
"""名称主体为空(只剩符号+数字)时不动,避免产出空标题"""
|
||
clean, changed = sanitize_kr_name("≥2", 2500)
|
||
assert changed is False
|
||
assert clean == "≥2"
|
||
|
||
def test_non_string_input(self):
|
||
clean, changed = sanitize_kr_name(None, 100)
|
||
assert changed is False and clean == ""
|
||
|
||
def test_target_number_parsing(self):
|
||
assert target_number("≥45天") == 45
|
||
assert target_number("≥20%") == 20
|
||
assert target_number(780) == 780
|
||
assert target_number("为正") is None
|
||
assert target_number(None) is None
|
||
|
||
|
||
# ────────────────────────── 2. 透传点护栏 ──────────────────────────
|
||
|
||
def _dims_with_krs(name, target_value):
|
||
return [
|
||
{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C",
|
||
"objectives": [{"name": "营收目标", "kpis": [],
|
||
"krs": [{"name": name, "target_value": target_value,
|
||
"weight": 70, "operator": ">="}]}]},
|
||
{"key": "customer", "name": "客户层", "icon": "🤝", "color": "#67c23a", "objectives": []},
|
||
]
|
||
|
||
|
||
class TestPassThroughGuards:
|
||
def _setup(self, db: Session):
|
||
user = create_test_user(db)
|
||
return user
|
||
|
||
def test_map_save_strips_polluted_kr_title(self, client: TestClient, db: Session):
|
||
"""PUT /maps/{id} 的 JSON→krs.title 同步点:污染名被规整(库 + JSON 双清)"""
|
||
self._setup(db)
|
||
token = get_token_for_user(client)
|
||
m = StrategicMap(title="测试地图", status="draft", entity_id=1,
|
||
dimensions=_dims_with_krs("营业收入≥2", 25000),
|
||
canvas_data={"connections": []})
|
||
db.add(m)
|
||
db.commit()
|
||
db.refresh(m)
|
||
|
||
resp = client.put(f"/api/cma/maps/{m.id}", headers=auth_header(token),
|
||
json={"dimensions": _dims_with_krs("营业收入≥2", 25000),
|
||
"base_updated_at": m.updated_at.isoformat()})
|
||
assert resp.status_code == 200, resp.text
|
||
|
||
kr = db.query(KR).filter(KR.objective_id.isnot(None)).first()
|
||
assert kr is not None and kr.title == "营业收入"
|
||
assert float(kr.target_value) == 25000.0
|
||
# JSON 回写:展示口径 = 落库口径(回查须先失效身份映射,防读到旧快照)
|
||
db.expire_all()
|
||
m2 = db.query(StrategicMap).filter(StrategicMap.id == m.id).first()
|
||
dims = m2.dimensions
|
||
dims = json.loads(dims) if isinstance(dims, str) else dims
|
||
assert dims[0]["objectives"][0]["krs"][0]["name"] == "营业收入"
|
||
|
||
def test_map_save_keeps_legal_kr_title(self, client: TestClient, db: Session):
|
||
"""阴性对照:合法命名(数字==目标值)不得被改"""
|
||
self._setup(db)
|
||
token = get_token_for_user(client)
|
||
m = StrategicMap(title="测试地图", status="draft", entity_id=1,
|
||
dimensions=_dims_with_krs("渠道留存率≥90", 90),
|
||
canvas_data={"connections": []})
|
||
db.add(m)
|
||
db.commit()
|
||
db.refresh(m)
|
||
|
||
resp = client.put(f"/api/cma/maps/{m.id}", headers=auth_header(token),
|
||
json={"dimensions": _dims_with_krs("渠道留存率≥90", 90),
|
||
"base_updated_at": m.updated_at.isoformat()})
|
||
assert resp.status_code == 200, resp.text
|
||
kr = db.query(KR).filter(KR.objective_id.isnot(None)).first()
|
||
assert kr is not None and kr.title == "渠道留存率≥90"
|
||
|
||
def test_kr_sync_endpoint_strips_polluted_title(self, client: TestClient, db: Session):
|
||
"""PUT /okr/{oid}/krs/sync(画布节点同步)同样被护栏覆盖"""
|
||
self._setup(db)
|
||
token = get_token_for_user(client)
|
||
obj = Objective(entity_id=1, title="营收目标", dimension="finance", status="active",
|
||
quarter="2026Q3")
|
||
db.add(obj)
|
||
db.commit()
|
||
db.refresh(obj)
|
||
|
||
resp = client.put(f"/api/cma/okr/{obj.id}/krs/sync", headers=auth_header(token),
|
||
json={"krs": [{"title": "新客户数≥1", "operator": ">=", "target_value": 100,
|
||
"weight": 30}]})
|
||
assert resp.status_code == 200, resp.text
|
||
krs = db.query(KR).filter(KR.objective_id == obj.id).all()
|
||
assert [k.title for k in krs] == ["新客户数"]
|
||
|
||
def test_apply_template_strips_polluted_kr_name(self, client: TestClient, db: Session):
|
||
"""POST /okr-templates/{id}/apply:存量污染模板 → 新地图 KR 名干净"""
|
||
self._setup(db)
|
||
token = get_token_for_user(client)
|
||
t = OKRTemplate(name="营收目标", description="", dimension="finance", layer="level3",
|
||
industry_tag="general", source="system",
|
||
preset_krs=[{"name": "营业收入≥2", "target_value": 2500, "weight": 70},
|
||
{"name": "渠道留存率≥90", "target_value": 90, "weight": 30}])
|
||
db.add(t)
|
||
db.commit()
|
||
db.refresh(t)
|
||
|
||
resp = client.post(f"/api/cma/okr-templates/{t.id}/apply", headers=auth_header(token),
|
||
json={"title": "套用模板-护栏验证"})
|
||
assert resp.status_code == 200, resp.text
|
||
m = db.query(StrategicMap).filter(StrategicMap.id == resp.json()["map_id"]).first()
|
||
names = [k["name"] for k in m.dimensions[0]["objectives"][0]["krs"]]
|
||
assert names == ["营业收入", "渠道留存率≥90"]
|