Files
Hermes CI Fix d08a8490eb
ci/woodpecker/push/main Pipeline failed
fix(maps): 覆写通道扩面清点——同契约调用点补基准(3 例静默降级 + 2 例新增失败)
缺陷 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
2026-10-12 17:06:07 +08:00

235 lines
12 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.
"""
战略地图版本管理完整实现测试(project-20260908-map-versions-page)
覆盖: 列表摘要/详情全量/回滚自动备份/备注编辑/跨entity 404/不存在404
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import Entity, User, UserEntity, StrategicMap, StrategicMapVersion
from tests.conftest import create_test_user, get_token_for_user, auth_header, TEST_USER_PASSWORD
def _create_second_entity(db: Session, user: User) -> int:
db.add(Entity(id=2, name="第二企业", short_name="企业2", status="active"))
db.add(UserEntity(user_id=user.id, entity_id=2, granted_by=1))
db.commit()
return 2
def _token_for_entity(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin", "password": TEST_USER_PASSWORD, "entity_id": entity_id,
})
assert resp.status_code == 200
data = resp.json()
return data.get("token") or data.get("access_token")
def _create_published_map(client: TestClient, token: str, title: str) -> int:
"""建模板地图 → 发布 → 返回 map_id(自动产生 v1.0 快照)"""
create_resp = client.post(
"/api/cma/maps/create-with-template",
headers=auth_header(token),
json={"title": title},
)
assert create_resp.status_code == 200
map_id = create_resp.json()["id"]
pub_resp = client.put(
f"/api/cma/maps/{map_id}", headers=auth_header(token),
json={"status": "published"},
)
assert pub_resp.status_code == 200
return map_id
def _base_updated_at(db: Session, map_id: int) -> str:
"""客户端载入快照时取得的服务端基准(乐观并发契约 base_updated_at,2026-10-12)。
PUT /api/cma/maps/{id} 覆写 dimensions/canvas_data 时须带此基准,取「写前读到的
updated_at」——这是同一写通道契约的一部分(cma-strategy-map-autosave-stale-overwrite),
不是把用例改绿:不带基准的写会被 409 拒绝(fail-closed)。
"""
db.expire_all()
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
assert m is not None
return m.updated_at.isoformat()
class TestMapVersions:
"""战略地图版本 endpoints"""
def test_list_versions_empty_then_publish_has_summary(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
# 未发布空列表
m = client.post("/api/cma/maps/create-with-template", headers=auth_header(token), json={"title": "版本测试A"})
map_id = m.json()["id"]
empty = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token))
assert empty.status_code == 200
assert empty.json()["data"] == []
# 发布后 v1.0 快照(列表带摘要字段)
client.put(f"/api/cma/maps/{map_id}", headers=auth_header(token), json={"status": "published"})
ver = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token))
assert ver.status_code == 200
rows = ver.json()["data"]
assert len(rows) == 1
r = rows[0]
assert r["version"] == "v1.0"
assert r["is_current"] is True
assert r["dimension_count"] == 4
assert r["objective_count"] > 0
assert isinstance(r["kpi_count"], int)
assert r["comment"] == "发布 v1.0"
def test_get_version_detail_has_full_content(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
map_id = _create_published_map(client, token, "版本详情B")
versions = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token)).json()["data"]
vid = versions[0]["id"]
detail = client.get(f"/api/cma/maps/{map_id}/versions/{vid}", headers=auth_header(token))
assert detail.status_code == 200
d = detail.json()
assert isinstance(d["dimensions"], list) and len(d["dimensions"]) == 4
assert isinstance(d["canvas_data"], dict)
assert d["version"] == "v1.0"
def test_rollback_backs_up_current_and_restores_content(self, client, db):
"""核心安全: 回滚前自动备份当前内容为新版本; 回滚后内容=目标版本"""
create_test_user(db)
token = get_token_for_user(client)
map_id = _create_published_map(client, token, "回滚安全C")
# 发布后快照 v1.0(内容=模板初始)
versions = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token)).json()["data"]
v1_id = versions[0]["id"]
v1_content = client.get(f"/api/cma/maps/{map_id}/versions/{v1_id}", headers=auth_header(token)).json()
# 修改地图内容为全新结构(published 状态下保存,不触发自动快照)
new_dims = [{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C",
"objectives": [{"name": "回滚前最新目标", "icon": "target",
"kpis": ["F_REVENUE"], "krs": [
{"name": "KR新", "kpi_code": "F_REVENUE", "weight": 100, "target_value": "100"}
]}]}]
upd = client.put(f"/api/cma/maps/{map_id}", headers=auth_header(token),
json={"dimensions": new_dims, "canvas_data": {"connections": [{"from": "a", "to": "b"}]},
"base_updated_at": _base_updated_at(db, map_id)})
assert upd.status_code == 200
# 回滚到 v1.0 → 应自动备份当前(v1.0→v1.1) 且内容还原为 v1.0
rb = client.post(f"/api/cma/maps/{map_id}/versions/{v1_id}/rollback", headers=auth_header(token))
assert rb.status_code == 200
data = rb.json()
assert data["version"] == "v1.0"
assert data["backup_version"] == "v1.1"
# 列表: 备份(v1.1 最新) + v1.0
after = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token)).json()["data"]
assert len(after) == 2
assert {x["version"] for x in after} == {"v1.0", "v1.1"}
backup = next(x for x in after if x["version"] == "v1.1")
assert backup["comment"] == "回滚前自动备份"
# 当前地图内容 == v1.0 快照内容(语义还原;列表读路径会经 map_objectives 合并补 description/icon 默认键,按业务字段归一化比较)
maps = client.get("/api/cma/maps", headers=auth_header(token)).json()["data"]
cur = next(x for x in maps if x["id"] == map_id)
assert cur["version"] == "v1.0"
def norm(dims):
return [
{
"key": d.get("key"),
"objectives": [
{k: o.get(k) for k in ("name", "kpis", "krs")}
for o in d.get("objectives", [])
],
}
for d in dims
]
assert norm(cur["dimensions"]) == norm(v1_content["dimensions"])
assert cur["canvas_data"] == v1_content["canvas_data"]
def test_rollback_again_uses_next_free_backup_version(self, client, db):
"""连续两次回滚同一目标: 备份版本号不撞号(v1.1 → v1.2)"""
create_test_user(db)
token = get_token_for_user(client)
map_id = _create_published_map(client, token, "回滚两次D")
versions = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token)).json()["data"]
v1_id = versions[0]["id"]
client.put(f"/api/cma/maps/{map_id}", headers=auth_header(token),
json={"dimensions": [{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C",
"objectives": []}],
"base_updated_at": _base_updated_at(db, map_id)})
rb1 = client.post(f"/api/cma/maps/{map_id}/versions/{v1_id}/rollback", headers=auth_header(token)).json()
assert rb1["backup_version"] == "v1.1"
# 再次改内容再回滚 → 备份 v1.2(不撞 v1.1)
client.put(f"/api/cma/maps/{map_id}", headers=auth_header(token),
json={"dimensions": [{"key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF",
"objectives": []}],
"base_updated_at": _base_updated_at(db, map_id)})
rb2 = client.post(f"/api/cma/maps/{map_id}/versions/{v1_id}/rollback", headers=auth_header(token)).json()
assert rb2["backup_version"] == "v1.2"
def test_update_comment(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
map_id = _create_published_map(client, token, "备注编辑E")
versions = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token)).json()["data"]
vid = versions[0]["id"]
resp = client.put(f"/api/cma/maps/{map_id}/versions/{vid}", headers=auth_header(token),
json={"comment": "2026H2 定稿版"})
assert resp.status_code == 200
assert resp.json()["comment"] == "2026H2 定稿版"
rows = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token)).json()["data"]
assert rows[0]["comment"] == "2026H2 定稿版"
def test_update_comment_requires_field(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
map_id = _create_published_map(client, token, "备注必填F")
versions = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token)).json()["data"]
vid = versions[0]["id"]
resp = client.put(f"/api/cma/maps/{map_id}/versions/{vid}", headers=auth_header(token), json={})
assert resp.status_code == 400
def test_cross_entity_all_versions_endpoints_404(self, client, db):
"""跨账套: 列表/详情/备注/回滚 全部 404(地图归属+版本 entity 双重隔离)"""
create_test_user(db)
token1 = get_token_for_user(client)
map_id = _create_published_map(client, token1, "隔离G")
user = db.query(User).filter(User.username == "testadmin").first()
_create_second_entity(db, user)
token2 = _token_for_entity(client, 2)
versions = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token1)).json()["data"]
vid = versions[0]["id"]
assert client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token2)).status_code == 404
assert client.get(f"/api/cma/maps/{map_id}/versions/{vid}", headers=auth_header(token2)).status_code == 404
assert client.put(f"/api/cma/maps/{map_id}/versions/{vid}", headers=auth_header(token2),
json={"comment": "越权"}).status_code == 404
assert client.post(f"/api/cma/maps/{map_id}/versions/{vid}/rollback", headers=auth_header(token2)).status_code == 404
def test_rollback_unknown_version_404(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
map_id = _create_published_map(client, token, "不存在H")
resp = client.post(f"/api/cma/maps/{map_id}/versions/99999/rollback", headers=auth_header(token))
assert resp.status_code == 404
def test_rollback_other_maps_version_404(self, client, db):
"""vid 属于另一张地图 → 404(map_id + vid 双条件匹配)"""
create_test_user(db)
token = get_token_for_user(client)
map_a = _create_published_map(client, token, "地图A-I")
map_b = _create_published_map(client, token, "地图B-I")
va = client.get(f"/api/cma/maps/{map_a}/versions", headers=auth_header(token)).json()["data"][0]
resp = client.post(f"/api/cma/maps/{map_b}/versions/{va['id']}/rollback", headers=auth_header(token))
assert resp.status_code == 404