D1 GET /api/cma/maps/completeness/objectives 恒 422:真抢占者是先注册的
objectives.router GET /{map_id}/objectives(map_id 被当 int 解析)。把
map_completeness / map_summary 两个字面路径路由器前移到 objectives 之前,
不动 prefix、不删参数化路由。新增同前缀族全量巡检用例。
D2 DELETE /{id}/connections 返回 200 不落库:canvas_data 为裸 Column(JSON),
就地/换列表赋值不被 ORM 判脏。模型改 MutableDict.as_mutable(JSON)(不改 DDL),
add_connection / delete_connection_by_key 均显式 flag_modified 双保险。
D3 窄视口 .kp-panel(fixed/z-1000) 遮画布,onTreeDragOver 用 elementFromPoint
取节点恒 null ⇒ 拖拽建连线失效。改为遍历画布内 [data-node-key] 做 rect 包含
判定、多点取中心最近者,不再回退 elementFromPoint。
测试:新增 backend/tests/test_map_route_shadowing_20261020.py(路由解析断言到
endpoint.__module__)与 test_map_connection_persistence_20261020.py(新 session
原生 SQL 复读 + 负测)。零生产写、零上线。
129 lines
4.6 KiB
Python
129 lines
4.6 KiB
Python
"""D2(2026-10-20 fullstack-20261020-strategy-map-3defects-fix)
|
||
|
||
缺陷:`DELETE /api/cma/maps/{id}/connections` 返回 200 但库里没变(POST 同样不可靠)。
|
||
根因:`StrategicMap.canvas_data` 是裸 `Column(JSON)`,无变更跟踪;接口用
|
||
「`m.canvas_data["connections"] = new_conns`」就地改,flush 时该列未被判脏 ⇒ 不写库。
|
||
|
||
判据铁律(任务 §1.2):**不能**用「响应体里 connections 变短了」当落库证据 ——
|
||
必须新开 session 用原生 SQL 复读 `canvas_data`。
|
||
"""
|
||
import json
|
||
|
||
from sqlalchemy import text
|
||
|
||
from app.models import StrategicMap
|
||
|
||
|
||
def _raw_canvas_data(map_id: int) -> dict:
|
||
"""**新 session + 原生 SQL** 复读(不复用请求内 session)。"""
|
||
from tests.conftest import TEST_SESSION_LOCAL
|
||
|
||
session = TEST_SESSION_LOCAL()
|
||
try:
|
||
row = session.execute(
|
||
text("SELECT canvas_data FROM strategic_maps WHERE id = :mid"), {"mid": map_id}
|
||
).first()
|
||
assert row is not None, f"strategic_maps.id={map_id} 不存在"
|
||
raw = row[0]
|
||
finally:
|
||
session.close()
|
||
if isinstance(raw, (str, bytes)):
|
||
return json.loads(raw)
|
||
return raw or {}
|
||
|
||
|
||
def _pairs(canvas: dict):
|
||
return [(c.get("from"), c.get("to")) for c in (canvas.get("connections") or [])]
|
||
|
||
|
||
def test_add_connection_persists_across_sessions(client, db):
|
||
from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user
|
||
|
||
create_test_user(db)
|
||
m = create_test_map(db, entity_id=1) # canvas_data={"connections": []}
|
||
db.commit()
|
||
token = get_token_for_user(client)
|
||
h = auth_header(token)
|
||
|
||
resp = client.post(
|
||
f"/api/cma/maps/{m.id}/connections",
|
||
json={"from": "learning-0", "to": "process-0"},
|
||
headers=h,
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
assert ("learning-0", "process-0") in _pairs(_raw_canvas_data(m.id)), (
|
||
"POST /connections 返回 200 但新 session 原生 SQL 读不到该连线(未落库)"
|
||
)
|
||
|
||
|
||
def test_delete_connection_persists_across_sessions(client, db):
|
||
from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user
|
||
|
||
create_test_user(db)
|
||
m = create_test_map(
|
||
db,
|
||
entity_id=1,
|
||
canvas_data={"connections": [
|
||
{"from": "learning-0", "to": "process-0", "style": "solid", "effect": "positive", "label": ""},
|
||
{"from": "process-0", "to": "customer-0", "style": "solid", "effect": "positive", "label": ""},
|
||
]},
|
||
)
|
||
db.commit()
|
||
token = get_token_for_user(client)
|
||
h = auth_header(token)
|
||
|
||
resp = client.request(
|
||
"DELETE",
|
||
f"/api/cma/maps/{m.id}/connections",
|
||
json={"from": "learning-0", "to": "process-0"},
|
||
headers=h,
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
|
||
persisted = _pairs(_raw_canvas_data(m.id))
|
||
assert ("learning-0", "process-0") not in persisted, (
|
||
f"DELETE 返回 200 但新 session 原生 SQL 仍读到已删连线:{persisted}"
|
||
)
|
||
assert ("process-0", "customer-0") in persisted, "无关连线被误删"
|
||
|
||
|
||
def test_delete_missing_connection_still_404(client, db):
|
||
"""负测:删除不存在的 from/to 仍 404(不得因标脏改动变成 200)。"""
|
||
from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user
|
||
|
||
create_test_user(db)
|
||
m = create_test_map(db, entity_id=1)
|
||
db.commit()
|
||
token = get_token_for_user(client)
|
||
h = auth_header(token)
|
||
|
||
resp = client.request(
|
||
"DELETE",
|
||
f"/api/cma/maps/{m.id}/connections",
|
||
json={"from": "learning-9", "to": "process-9"},
|
||
headers=h,
|
||
)
|
||
assert resp.status_code == 404, resp.text
|
||
|
||
|
||
def test_canvas_data_mutation_tracked_by_mutable_dict(db):
|
||
"""模型层双保险:MutableDict 包装后,**不经 flag_modified** 的 dict 赋值也应落库。
|
||
|
||
说明:`MutableDict.as_mutable(JSON)` 返回的仍是 JSON 类型实例(跟踪靠 mapper 级
|
||
attribute 事件 + coerce),因此这里用**行为断言**而不是断言列类型类名。
|
||
"""
|
||
m = StrategicMap(title="MutableDict 行为验证", entity_id=1, canvas_data={"connections": []})
|
||
db.add(m)
|
||
db.commit()
|
||
mid = m.id
|
||
db.expire_all()
|
||
|
||
loaded = db.query(StrategicMap).filter(StrategicMap.id == mid).first()
|
||
# 就地改 dict(等价于接口里的 `m.canvas_data["connections"] = new_conns`)
|
||
loaded.canvas_data["connections"] = [{"from": "learning-0", "to": "process-0"}]
|
||
db.commit() # 不调用 flag_modified
|
||
|
||
assert ("learning-0", "process-0") in _pairs(_raw_canvas_data(mid)), (
|
||
"canvas_data 未被 MutableDict 跟踪:dict 赋值后 commit 未落库"
|
||
)
|