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 复读 + 负测)。零生产写、零上线。
109 lines
4.4 KiB
Python
109 lines
4.4 KiB
Python
"""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)
|