Files
cma-management/backend/tests/test_map_route_shadowing_20261020.py
Hermes CI Fix b736e0727a 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 复读 + 负测)。零生产写、零上线。
2026-10-21 02:04:28 +08:00

109 lines
4.4 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.
"""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)