6c6f690788
- 移除冗余依赖包 - 新增账号禁用校验与用户管理能力 - 新增文档下载与管理页面文件展示 - 新增API文档页面与用户管理前端页面 - 重构时区处理与docker-compose部署配置 - 完善测试用例与项目文档
516 lines
22 KiB
Python
516 lines
22 KiB
Python
"""管理页面挂载测试(GET /admin)
|
||
|
||
覆盖:
|
||
1. 路由存在性:200 + content-type 为 text/html
|
||
2. 五区块可识别标记(文案与 section id)
|
||
3. 零外部依赖:无 http(s) 外链资源、无 CDN 引用
|
||
4. fetch 调用路径与后端 API 契约一致(含 /api/v1/auth/*)
|
||
5. 删除/重置操作的通用 overlay 弹窗组件(替换原生 prompt/confirm)
|
||
6. 登录门禁:登录卡片、localStorage key、/auth/me 验证、/auth/login 路径
|
||
7. 顶栏用户区:用户名、角色徽章、修改密码、退出登录
|
||
8. 修改密码:旧/新/确认表单、must_change_password 强制改密
|
||
9. 用户管理区块:列表/创建表单/角色下拉/重置密码弹窗/删除确认弹窗、admin 角色门禁
|
||
10. 请求拦截:Authorization Bearer 注入、1005 回登录、1006 错误条
|
||
11. API 指南区块:导航/section、API_GUIDE 清单与真实路由一致性、试一下面板、
|
||
curl 复制、auth 标注、upload 文件选择、禁止自定义 URL
|
||
"""
|
||
|
||
import re
|
||
from collections.abc import Iterator
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.main import app
|
||
from app.services.qdrant import QdrantService
|
||
|
||
|
||
@pytest.fixture
|
||
def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
||
"""TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作"""
|
||
|
||
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||
return None
|
||
|
||
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||
with TestClient(app) as test_client:
|
||
yield test_client
|
||
|
||
|
||
@pytest.fixture
|
||
def admin_html(client: TestClient) -> str:
|
||
"""请求 /admin 并返回 HTML 文本(前置断言 200)"""
|
||
resp = client.get("/admin")
|
||
assert resp.status_code == 200
|
||
return resp.text
|
||
|
||
|
||
def test_admin_page_ok(client: TestClient) -> None:
|
||
"""GET /admin → 200,content-type 含 text/html"""
|
||
resp = client.get("/admin")
|
||
|
||
assert resp.status_code == 200
|
||
assert "text/html" in resp.headers["content-type"]
|
||
|
||
|
||
def test_admin_page_has_five_sections(admin_html: str) -> None:
|
||
"""HTML 含五个区块的文案与对应 section id"""
|
||
for section_id in (
|
||
"section-overview",
|
||
"section-docs",
|
||
"section-ingest",
|
||
"section-search",
|
||
"section-categories",
|
||
):
|
||
assert section_id in admin_html
|
||
for label in ("概览", "文档管理", "文档入库", "检索测试台", "类目列表"):
|
||
assert label in admin_html
|
||
|
||
|
||
def test_admin_page_no_external_resources(admin_html: str) -> None:
|
||
"""零外部依赖:无 src/href http(s) 外链,无 CDN 引用"""
|
||
assert re.search(r'src=["\']https?://', admin_html) is None
|
||
assert re.search(r'href=["\']https?://', admin_html) is None
|
||
assert re.search(r"//cdn", admin_html) is None
|
||
|
||
|
||
def test_admin_page_fetch_paths(admin_html: str) -> None:
|
||
"""fetch 调用路径与后端 API 契约一致(含认证相关端点)"""
|
||
for path in (
|
||
"/api/v1/search",
|
||
"/api/v1/documents",
|
||
"/api/v1/knowledge/stats",
|
||
"/api/v1/knowledge/categories",
|
||
"/api/v1/auth/login",
|
||
"/api/v1/auth/me",
|
||
"/api/v1/auth/logout",
|
||
"/api/v1/auth/password",
|
||
"/api/v1/auth/users",
|
||
):
|
||
assert path in admin_html
|
||
|
||
|
||
def _collect_route_paths(routes: list) -> set[str]:
|
||
"""递归收集路由路径(FastAPI 0.140+ include_router 包装为 _IncludedRouter)"""
|
||
paths: set[str] = set()
|
||
for route in routes:
|
||
path = getattr(route, "path", None)
|
||
if path:
|
||
paths.add(path)
|
||
sub_router = getattr(route, "original_router", None)
|
||
if sub_router is not None:
|
||
paths |= _collect_route_paths(sub_router.routes)
|
||
return paths
|
||
|
||
|
||
def test_admin_page_fetch_paths_in_real_routes(admin_html: str) -> None:
|
||
"""页面 api() 调用路径均在真实后端路由集合内(含 tasks 与 /api/v1/auth/* 路径)"""
|
||
route_paths = _collect_route_paths(app.routes)
|
||
assert "/api/v1/documents/tasks/{task_id}" in route_paths
|
||
for auth_path in (
|
||
"/api/v1/auth/login",
|
||
"/api/v1/auth/me",
|
||
"/api/v1/auth/logout",
|
||
"/api/v1/auth/password",
|
||
"/api/v1/auth/users",
|
||
):
|
||
assert auth_path in route_paths
|
||
|
||
prefixes = set(re.findall(r'api\("([^"?]+)', admin_html))
|
||
assert prefixes, "页面应包含 api() 调用"
|
||
for prefix in prefixes:
|
||
assert any(path == prefix or path.startswith(prefix) for path in route_paths), (
|
||
f"页面 fetch 路径 {prefix} 不在后端路由集合内"
|
||
)
|
||
|
||
|
||
def test_admin_page_ingest_polling(admin_html: str) -> None:
|
||
"""入库区块:异步任务轮询逻辑标记"""
|
||
# 轮询任务状态端点
|
||
assert "/api/v1/documents/tasks/" in admin_html
|
||
# 状态中文映射
|
||
for text in ("排队中", "总结中", "分类中", "向量化中", "写入中", "完成", "失败"):
|
||
assert text in admin_html
|
||
# 提交确认与超时提示
|
||
assert "任务已提交" in admin_html
|
||
assert "任务仍在进行,可稍后凭 task_id 查询" in admin_html
|
||
# 5 分钟超时:150 次 × 2s
|
||
assert "150" in admin_html
|
||
# 防重复提交:轮询中禁用提交按钮
|
||
assert "disabled" in admin_html
|
||
|
||
|
||
def test_admin_page_no_prompt_confirm_calls(admin_html: str) -> None:
|
||
"""清理:弹窗组件已替换原生 prompt/confirm,全页面无 prompt( 与 confirm( 调用残留"""
|
||
assert "prompt(" not in admin_html
|
||
assert "confirm(" not in admin_html
|
||
|
||
|
||
def test_admin_page_login_card(admin_html: str) -> None:
|
||
"""登录门禁:登录卡片、localStorage key、/auth/me 验证与 /auth/login 路径"""
|
||
assert 'id="login-overlay"' in admin_html
|
||
assert 'id="login-form"' in admin_html
|
||
assert 'id="login-username"' in admin_html
|
||
assert 'id="login-password"' in admin_html
|
||
assert "登录" in admin_html
|
||
# token 与用户信息持久化 key
|
||
assert "qmd_token" in admin_html
|
||
assert "qmd_user" in admin_html
|
||
assert "localStorage" in admin_html
|
||
# 页面加载时经 /auth/me 验证登录态
|
||
assert "/api/v1/auth/me" in admin_html
|
||
# 登录请求路径
|
||
assert "/api/v1/auth/login" in admin_html
|
||
|
||
|
||
def test_admin_page_user_area(admin_html: str) -> None:
|
||
"""顶栏用户区:用户名、角色徽章、修改密码与退出登录按钮"""
|
||
assert 'id="user-area"' in admin_html
|
||
assert 'id="user-name"' in admin_html
|
||
assert 'id="user-role"' in admin_html
|
||
assert "role-badge" in admin_html
|
||
assert 'id="btn-change-password"' in admin_html
|
||
assert "修改密码" in admin_html
|
||
assert 'id="btn-logout"' in admin_html
|
||
assert "退出登录" in admin_html
|
||
# 退出登录调用后端 logout 端点并清理 localStorage
|
||
assert "/api/v1/auth/logout" in admin_html
|
||
assert "clearAuth(" in admin_html
|
||
|
||
|
||
def test_admin_page_password_form(admin_html: str) -> None:
|
||
"""修改密码:旧/新/确认表单、改密端点与 must_change_password 强制改密层"""
|
||
assert 'id="password-overlay"' in admin_html
|
||
assert 'id="password-form"' in admin_html
|
||
assert 'id="password-old"' in admin_html
|
||
assert 'id="password-new"' in admin_html
|
||
assert 'id="password-confirm"' in admin_html
|
||
assert "/api/v1/auth/password" in admin_html
|
||
# must_change_password 登录后强制改密:提示语 + 取消按钮在强制模式隐藏
|
||
assert "must_change_password" in admin_html
|
||
assert "首次登录须" in admin_html
|
||
assert 'id="password-forced-tip"' in admin_html
|
||
assert 'id="btn-password-cancel"' in admin_html
|
||
assert "passwordForced" in admin_html
|
||
|
||
|
||
def test_admin_page_profile_section(admin_html: str) -> None:
|
||
"""个人中心区块:导航按钮、section、账号信息卡片、改密表单、退出按钮、
|
||
user 角色默认进入、GET /auth/me 与 POST /auth/password 路径"""
|
||
# section 与导航按钮(不限 admin,所有登录用户可见)
|
||
assert 'id="section-profile"' in admin_html
|
||
assert 'id="nav-profile"' in admin_html
|
||
assert 'data-target="section-profile"' in admin_html
|
||
assert "个人中心" in admin_html
|
||
# 账号信息卡片字段:用户名 / 角色 / 创建时间 / 须改密 / 启用状态
|
||
assert 'id="profile-cards"' in admin_html
|
||
for label in ("用户名", "角色", "创建时间", "须改密", "启用状态"):
|
||
assert label in admin_html
|
||
# 改密表单:旧密码 / 新密码 / 确认 / 强度提示 / 提交按钮
|
||
assert 'id="profile-password-form"' in admin_html
|
||
assert 'id="profile-old-password"' in admin_html
|
||
assert 'id="profile-new-password"' in admin_html
|
||
assert 'id="profile-confirm-password"' in admin_html
|
||
assert 'id="profile-strength-hint"' in admin_html
|
||
assert 'id="btn-profile-password-submit"' in admin_html
|
||
# 强度提示复用 Task 3 文案(<8 弱 / ≥8 中 / ≥12 强)
|
||
assert "密码强度:" in admin_html
|
||
for level in ("弱", "中", "强"):
|
||
assert level in admin_html
|
||
# 两次不一致本地拦截
|
||
assert "两次输入的新密码不一致" in admin_html
|
||
# 提交按钮 loading 态
|
||
assert "提交中…" in admin_html
|
||
# 退出登录按钮(复用现有 logout 逻辑)
|
||
assert 'id="btn-profile-logout"' in admin_html
|
||
assert "退出登录" in admin_html
|
||
assert "doLogout" in admin_html
|
||
# 数据来源:GET /auth/me 加载账号信息;POST /auth/password 改密
|
||
assert "/api/v1/auth/me" in admin_html
|
||
assert "/api/v1/auth/password" in admin_html
|
||
# 个人中心加载与渲染函数
|
||
assert "loadProfile" in admin_html
|
||
assert "renderProfile" in admin_html
|
||
# user 角色默认进个人中心(admin 仍默认进概览)
|
||
assert 'activateSection("section-profile")' in admin_html
|
||
assert 'activateSection("section-overview")' in admin_html
|
||
# activateSection 触发 loadProfile
|
||
assert 'targetId === "section-profile"' in admin_html
|
||
|
||
|
||
def test_admin_page_users_section(admin_html: str) -> None:
|
||
"""用户管理区块:用户列表/创建表单/角色下拉/重置密码弹窗/删除确认弹窗/角色门禁"""
|
||
assert 'id="section-users"' in admin_html
|
||
assert 'id="users-tbody"' in admin_html
|
||
# 表头列:用户名/角色/须改密/创建时间/操作
|
||
for col in ("用户名", "角色", "须改密", "创建时间", "操作"):
|
||
assert col in admin_html
|
||
# 创建用户表单与角色下拉
|
||
assert 'id="user-create-form"' in admin_html
|
||
assert 'id="user-new-name"' in admin_html
|
||
assert 'id="user-new-password"' in admin_html
|
||
assert 'id="user-new-role"' in admin_html
|
||
assert '<option value="admin"' in admin_html
|
||
assert '<option value="user"' in admin_html
|
||
assert "创建用户" in admin_html
|
||
# 操作列:重置密码 + 删除均走通用弹窗组件(无 prompt/confirm 调用)
|
||
assert "重置密码" in admin_html
|
||
assert "resetUserPassword" in admin_html
|
||
assert "deleteUser" in admin_html
|
||
# 用户管理端点
|
||
assert "/api/v1/auth/users" in admin_html
|
||
# 角色门禁:仅 admin 挂载该区块进 DOM,非 admin 完全不渲染
|
||
assert 'role === "admin"' in admin_html
|
||
assert "mountUsersSection" in admin_html
|
||
assert "unmountUsersSection" in admin_html
|
||
|
||
|
||
def test_admin_page_users_filter_and_inline_edit(admin_html: str) -> None:
|
||
"""用户管理:列表搜索筛选 + 行内角色编辑 + 启用开关 + 当前 admin 自身行防降级"""
|
||
# 列表搜索筛选:用户名搜索框 + 角色筛选下拉
|
||
assert 'id="user-search"' in admin_html
|
||
assert 'id="user-role-filter"' in admin_html
|
||
assert '<option value="all"' in admin_html
|
||
# 输入/变更事件触发本地过滤(不重新请求后端)
|
||
assert 'addEventListener("input", applyUsersFilter)' in admin_html
|
||
assert 'addEventListener("change", applyUsersFilter)' in admin_html
|
||
assert "applyUsersFilter" in admin_html
|
||
# 表头加「启用」列
|
||
assert "启用" in admin_html
|
||
# 行内角色编辑:select + 保存按钮(仅当值改变时启用)
|
||
assert "user-role-select" in admin_html
|
||
assert "user-role-save" in admin_html
|
||
assert "updateUserRole" in admin_html
|
||
# 行内启用/禁用开关:按钮文字 + 样式区分
|
||
assert "user-enabled-toggle" in admin_html
|
||
assert "toggleUserEnabled" in admin_html
|
||
# PATCH /auth/users/{username} 端点调用(role 与 enabled 两条分支)
|
||
assert 'method: "PATCH"' in admin_html
|
||
assert "encodeURIComponent(username)" in admin_html
|
||
# 当前登录 admin 自身行防自锁降级:role select disabled + 启用开关 disabled
|
||
assert "isSelf" in admin_html
|
||
assert "不能禁用当前登录账号" in admin_html
|
||
|
||
|
||
def test_admin_page_modal_component(admin_html: str) -> None:
|
||
"""通用 overlay 弹窗组件:modal-overlay/modal-card 结构 + openModal/closeModal API"""
|
||
# CSS 类
|
||
assert "modal-overlay" in admin_html
|
||
assert "modal-card" in admin_html
|
||
assert "modal-title" in admin_html
|
||
assert "modal-content" in admin_html
|
||
assert "modal-actions" in admin_html
|
||
# 挂载点容器
|
||
assert 'id="modal-root"' in admin_html
|
||
# 通用 JS API
|
||
assert "function openModal(" in admin_html
|
||
assert "function closeModal(" in admin_html
|
||
assert "function isModalSubmitting(" in admin_html
|
||
assert "function setModalSubmitting(" in admin_html
|
||
assert "function showModelError(" in admin_html
|
||
assert "function hideModelError(" in admin_html
|
||
# 提交中标记属性
|
||
assert "data-submitting" in admin_html
|
||
|
||
|
||
def test_admin_page_modal_cancel_and_loading(admin_html: str) -> None:
|
||
"""弹窗可取消(点遮罩/取消按钮/ESC)+ 提交中不可取消 + loading 态(提交中禁用)"""
|
||
# 点遮罩关闭:e.target === overlay
|
||
assert "e.target === overlay" in admin_html
|
||
# 取消按钮存在
|
||
assert "取消" in admin_html
|
||
# ESC 键关闭最顶层弹窗
|
||
assert "Escape" in admin_html
|
||
# 提交中不可关闭:遮罩点击与 ESC 均检查 data-submitting !== "true"
|
||
assert 'data-submitting") !== "true"' in admin_html
|
||
assert 'data-submitting") === "true"' in admin_html
|
||
# 提交 loading 文案 + 按钮禁用
|
||
assert "提交中…" in admin_html
|
||
assert 'submitBtn.disabled = true' in admin_html
|
||
# isModalSubmitting 守卫:取消按钮在提交中不响应
|
||
assert "isModalSubmitting(" in admin_html
|
||
|
||
|
||
def test_admin_page_reset_password_modal(admin_html: str) -> None:
|
||
"""重置密码弹窗:新密码/确认/强度提示/两次不一致本地拦截/loading 态"""
|
||
# 弹窗标题含用户名占位
|
||
assert "重置用户" in admin_html
|
||
assert "密码" in admin_html
|
||
# 新密码 + 确认密码输入框(动态创建,id 经 JS 属性赋值)
|
||
assert 'newInput.id = "modal-reset-new"' in admin_html
|
||
assert 'confirmInput.id = "modal-reset-confirm"' in admin_html
|
||
# 密码强度提示节点
|
||
assert 'hint.id = "modal-reset-strength"' in admin_html
|
||
assert "modal-strength-hint" in admin_html
|
||
# 强度三档纯文案:<8 弱 / ≥8 中 / ≥12 强
|
||
assert "密码强度:" in admin_html
|
||
for level in ("弱", "中", "强"):
|
||
assert level in admin_html
|
||
# 两次不一致本地拦截
|
||
assert "两次输入的新密码不一致" in admin_html
|
||
# 重置密码端点
|
||
assert "/api/v1/auth/users/" in admin_html
|
||
assert "/password" in admin_html
|
||
# 提交按钮 loading 态
|
||
assert "setModalSubmitting" in admin_html
|
||
|
||
|
||
def test_admin_page_delete_user_modal(admin_html: str) -> None:
|
||
"""删除确认弹窗:警告文案 + 输入用户名匹配才可提交 + loading 态"""
|
||
# 弹窗标题
|
||
assert "删除用户" in admin_html
|
||
# 警告文案
|
||
assert "此操作不可恢复,将清除该用户及其全部会话" in admin_html
|
||
# 「请输入用户名 {username} 以确认」+ 文本输入框(动态创建,id 经 JS 属性赋值)
|
||
assert "请输入用户名" in admin_html
|
||
assert "以确认" in admin_html
|
||
assert 'input.id = "modal-delete-input"' in admin_html
|
||
# 输入 !== username 时删除按钮禁用;匹配后启用
|
||
assert "submitBtn.disabled = true" in admin_html
|
||
assert "input.value !== username" in admin_html
|
||
# 删除端点
|
||
assert 'method: "DELETE"' in admin_html
|
||
# 提交按钮 loading 态
|
||
assert "setModalSubmitting" in admin_html
|
||
|
||
|
||
def test_admin_page_auth_interceptor(admin_html: str) -> None:
|
||
"""请求拦截:统一注入 Authorization Bearer;1005 回登录;1006 错误条提示"""
|
||
assert 'options.headers["Authorization"] = "Bearer " + token' in admin_html
|
||
# 1005 未认证/凭证无效 → 清 localStorage 并回登录卡片
|
||
assert "body.code === 1005" in admin_html
|
||
assert "clearAuth();" in admin_html
|
||
assert "showLogin();" in admin_html
|
||
# 1006 权限不足 → 错误码透传给错误条
|
||
assert "1006" in admin_html
|
||
|
||
|
||
def test_admin_page_doc_detail_file_link(admin_html: str) -> None:
|
||
"""文档详情区:有关联文件时展示原始文件行与下载链接(textContent 防 XSS)"""
|
||
# 条件判断与字段访问
|
||
assert "data.file" in admin_html
|
||
assert "file.filename" in admin_html
|
||
assert "file.size_bytes" in admin_html
|
||
# 大小格式化辅助
|
||
assert "formatFileSize" in admin_html
|
||
# 下载链接用 el("a") 创建,设 href 与 download 属性,不 innerHTML
|
||
assert 'el("a"' in admin_html
|
||
assert "download" in admin_html
|
||
# 行标签文案
|
||
assert "原始文件" in admin_html
|
||
assert "下载" in admin_html
|
||
|
||
|
||
def test_admin_page_api_guide_section(admin_html: str) -> None:
|
||
"""API 指南区块:导航按钮、section、静态清单与鉴权标注(user 角色也可见)"""
|
||
assert 'data-target="section-api-guide"' in admin_html
|
||
assert 'id="section-api-guide"' in admin_html
|
||
assert 'id="api-guide-list"' in admin_html
|
||
assert "API 指南" in admin_html
|
||
# 静态维护的端点清单
|
||
assert "API_GUIDE" in admin_html
|
||
# auth 中文标注:免登录 / 需登录 / 仅 admin
|
||
for label in ("免登录", "需登录", "仅 admin"):
|
||
assert label in admin_html
|
||
# 方法颜色徽章
|
||
for cls in ("method-get", "method-post", "method-delete"):
|
||
assert cls in admin_html
|
||
|
||
|
||
def test_admin_page_api_guide_try_panel(admin_html: str) -> None:
|
||
"""试一下面板:发送按钮/状态码/耗时/格式化 JSON 展示/curl 复制/upload 文件选择"""
|
||
assert "试一下" in admin_html
|
||
assert "try-panel" in admin_html
|
||
assert "try-toggle" in admin_html
|
||
# 发送按钮与结果区(状态码/耗时/pre 格式化展示)
|
||
assert "try-send" in admin_html
|
||
assert "发送" in admin_html
|
||
assert "状态码" in admin_html
|
||
assert "耗时" in admin_html
|
||
assert "try-result-pre" in admin_html
|
||
assert "JSON.stringify(JSON.parse(text), null, 2)" in admin_html
|
||
# 需鉴权端点自动带当前 token
|
||
assert 'ep.auth !== "none"' in admin_html
|
||
# body 非 JSON 本地报错不发请求
|
||
assert "未发送请求" in admin_html
|
||
# curl 示例与复制(clipboard + 降级 textarea 选中)
|
||
assert "curl 示例" in admin_html
|
||
assert "curl-copy" in admin_html
|
||
assert "复制" in admin_html
|
||
assert "navigator.clipboard" in admin_html
|
||
assert "execCommand" in admin_html
|
||
# base URL 用 location.origin 动态拼接
|
||
assert "location.origin" in admin_html
|
||
# upload 端点:file input + FormData(不带 Content-Type 头)
|
||
assert "try-file" in admin_html
|
||
assert 'fileInput.type = "file"' in admin_html
|
||
assert "FormData" in admin_html
|
||
|
||
|
||
def test_admin_page_api_guide_no_custom_url(admin_html: str) -> None:
|
||
"""安全约束:只能从清单选择端点,页面无自定义任意 URL 输入"""
|
||
assert "api-url" not in admin_html
|
||
assert "自定义 URL" not in admin_html.replace("不支持自定义 URL", "")
|
||
# 试一下面板只能从清单展开:端点 URL 由清单定义拼接,无自由输入
|
||
assert "不提供任意 URL 输入框" in admin_html
|
||
|
||
|
||
_API_GUIDE_ENTRY_RE = re.compile(r'method:\s*"(\w+)",\s*path:\s*"([^"]+)"')
|
||
|
||
|
||
def _extract_api_guide_entries(admin_html: str) -> list[tuple[str, str]]:
|
||
"""从页面 JS 提取 API_GUIDE 静态清单中的 (method, path) 列表"""
|
||
match = re.search(r"API_GUIDE\s*=\s*\[(.*?)\n\];", admin_html, re.S)
|
||
assert match, "页面应包含 API_GUIDE 静态清单"
|
||
return _API_GUIDE_ENTRY_RE.findall(match.group(1))
|
||
|
||
|
||
def _collect_route_methods(routes: list) -> set[tuple[str, str]]:
|
||
"""递归收集路由 (method, path) 对(FastAPI 0.140+ include_router 包装为 _IncludedRouter)"""
|
||
pairs: set[tuple[str, str]] = set()
|
||
for route in routes:
|
||
path = getattr(route, "path", None)
|
||
methods = getattr(route, "methods", None)
|
||
if path and methods:
|
||
for method in methods:
|
||
pairs.add((method, path))
|
||
sub_router = getattr(route, "original_router", None)
|
||
if sub_router is not None:
|
||
pairs |= _collect_route_methods(sub_router.routes)
|
||
return pairs
|
||
|
||
|
||
def test_admin_page_api_guide_paths_in_real_routes(admin_html: str) -> None:
|
||
"""API_GUIDE 每个 method+path 均在真实后端路由集合内,且全部真实端点被覆盖(防漂移)"""
|
||
entries = _extract_api_guide_entries(admin_html)
|
||
assert entries, "API_GUIDE 应至少包含一个端点"
|
||
|
||
route_methods = _collect_route_methods(app.routes)
|
||
for method, path in entries:
|
||
assert (method, path) in route_methods, f"API_GUIDE 端点 {method} {path} 不在后端路由集合内"
|
||
|
||
# 全部真实端点覆盖:health/search/documents*/knowledge*/auth*
|
||
expected = {
|
||
("GET", "/api/v1/health"),
|
||
("POST", "/api/v1/search"),
|
||
("POST", "/api/v1/documents"),
|
||
("POST", "/api/v1/documents/upload"),
|
||
("GET", "/api/v1/documents/tasks/{task_id}"),
|
||
("GET", "/api/v1/documents"),
|
||
("GET", "/api/v1/documents/{doc_id}"),
|
||
("DELETE", "/api/v1/documents/{doc_id}"),
|
||
("GET", "/api/v1/knowledge/categories"),
|
||
("GET", "/api/v1/knowledge/stats"),
|
||
("POST", "/api/v1/auth/login"),
|
||
("POST", "/api/v1/auth/logout"),
|
||
("POST", "/api/v1/auth/password"),
|
||
("GET", "/api/v1/auth/me"),
|
||
("GET", "/api/v1/auth/users"),
|
||
("POST", "/api/v1/auth/users"),
|
||
("POST", "/api/v1/auth/users/{username}/password"),
|
||
("DELETE", "/api/v1/auth/users/{username}"),
|
||
}
|
||
assert expected <= route_methods, f"后端缺少预期端点: {sorted(expected - route_methods)}"
|
||
missing = expected - set(entries)
|
||
assert not missing, f"API_GUIDE 缺少端点: {sorted(missing)}"
|