"""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)