fix(strategy-map): 战略地图三处 P1 缺陷(D1 完备度路由 422 / D2 连线删除不落库 / D3 窄屏拖拽失效)
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 复读 + 负测)。零生产写、零上线。
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
"""D1(2026-10-20 fullstack-20261020-strategy-map-3defects-fix)
|
||||
|
||||
缺陷:`GET /api/cma/maps/completeness/objectives` 恒 422。
|
||||
根因(项目Bot 复核修正):`main.py` 先 include 的 `objectives.router`
|
||||
(prefix `/api/cma/maps`,`GET /{map_id}/objectives`)把 `map_id="completeness"`
|
||||
当 int 解析 → 422;真抢占者不是 `maps.router`。
|
||||
|
||||
修复:`map_completeness.router` / `map_summary.router` 前移到 `objectives.router` 之前。
|
||||
本文件钉住三件事:
|
||||
1) 该 path 的首个 FULL match 落在 `app.api.map_completeness`(比 200/401 更硬,不依赖 DB);
|
||||
2) `/completeness/summary` 不回归;
|
||||
3) `/maps/{map_id}/objectives` 仍命中 `app.api.objectives`;
|
||||
4) 同前缀族全量巡检:任何字面路径路由都不得被先注册的参数化路由吃掉。
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.routing import APIRoute
|
||||
from starlette.routing import Match
|
||||
|
||||
from app.main import app
|
||||
|
||||
COMPLETENESS_OBJECTIVES = "/api/cma/maps/completeness/objectives"
|
||||
|
||||
|
||||
def _first_full_match(path: str, method: str = "GET"):
|
||||
"""按注册顺序返回首个 FULL match 的 route(= Starlette 实际分发结果)。"""
|
||||
scope = {"type": "http", "method": method, "path": path, "headers": []}
|
||||
for route in app.routes:
|
||||
if not hasattr(route, "matches"):
|
||||
continue
|
||||
match, _child = route.matches(scope)
|
||||
if match == Match.FULL:
|
||||
return route
|
||||
return None
|
||||
|
||||
|
||||
def _ep(route) -> str:
|
||||
endpoint = getattr(route, "endpoint", None)
|
||||
return f"{getattr(endpoint, '__module__', '?')}.{getattr(endpoint, '__name__', '?')}"
|
||||
|
||||
|
||||
def test_app_imported_from_this_worktree():
|
||||
"""防呆:pytest.ini 的 pythonpath 指向部署树,必须断言用的是当前工作区代码。"""
|
||||
from app import main as app_main
|
||||
|
||||
repo_backend = Path(__file__).resolve().parents[1]
|
||||
assert Path(app_main.__file__).resolve().is_relative_to(repo_backend), (
|
||||
f"导入了非本工作区的 app.main:{app_main.__file__}(期望在 {repo_backend} 下)"
|
||||
)
|
||||
|
||||
|
||||
def test_completeness_objectives_route_resolves_to_map_completeness():
|
||||
route = _first_full_match(COMPLETENESS_OBJECTIVES)
|
||||
assert route is not None, f"没有路由匹配 {COMPLETENESS_OBJECTIVES}"
|
||||
assert _ep(route) == "app.api.map_completeness.completeness_objectives", (
|
||||
f"{COMPLETENESS_OBJECTIVES} 被抢先解析为 {_ep(route)}"
|
||||
)
|
||||
assert getattr(route, "path", None) == COMPLETENESS_OBJECTIVES
|
||||
|
||||
|
||||
def test_completeness_summary_not_regressed():
|
||||
route = _first_full_match("/api/cma/maps/completeness/summary")
|
||||
assert route is not None
|
||||
assert _ep(route) == "app.api.map_completeness.completeness_summary", _ep(route)
|
||||
|
||||
|
||||
def test_map_summary_not_shadowed():
|
||||
route = _first_full_match("/api/cma/maps/summary")
|
||||
assert route is not None
|
||||
assert _ep(route) == "app.api.map_summary.map_summary", _ep(route)
|
||||
|
||||
|
||||
def test_map_objectives_endpoint_not_regressed():
|
||||
route = _first_full_match("/api/cma/maps/65/objectives")
|
||||
assert route is not None
|
||||
assert _ep(route) == "app.api.objectives.list_objectives", _ep(route)
|
||||
|
||||
|
||||
def test_no_literal_path_shadowed_in_maps_prefix_family():
|
||||
"""同前缀族全量巡检:/api/cma/maps* 下所有**字面**路径的首个匹配必须是自己。"""
|
||||
family = [
|
||||
r for r in app.routes
|
||||
if isinstance(r, APIRoute)
|
||||
and r.path.startswith("/api/cma/maps")
|
||||
and "{" not in r.path
|
||||
]
|
||||
assert family, "未发现 /api/cma/maps* 字面路径路由,巡检面为空(异常)"
|
||||
|
||||
problems = []
|
||||
for r in family:
|
||||
for method in sorted((r.methods or set()) - {"HEAD", "OPTIONS"}):
|
||||
first = _first_full_match(r.path, method)
|
||||
if first is not r:
|
||||
problems.append(f"{method} {r.path} -> {_ep(first)}(应为 {_ep(r)})")
|
||||
assert not problems, "同前缀族路由被抢占:\n" + "\n".join(problems)
|
||||
|
||||
|
||||
def test_completeness_objectives_http_not_422(client, db):
|
||||
"""端到端:带凭据请求不得出现 loc=["path","map_id"] / input="completeness" 的 422。"""
|
||||
from tests.conftest import auth_header, create_test_user, get_token_for_user
|
||||
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(COMPLETENESS_OBJECTIVES, headers=auth_header(token))
|
||||
assert resp.status_code != 422, resp.text
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert "objectives" in body or isinstance(body, dict)
|
||||
Reference in New Issue
Block a user