"""管理页面挂载测试(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 '