"""管理页面挂载测试(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 12. 入库进度区块(Task 2):section/nav、表格结构、2s 轮询启停、状态徽章复用、 done/failed 操作按钮、reingest 端点、批量上传 multiple + upload-batch 路径 """ 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 并返回旧版 admin.html 文本(前置断言 200) 当 app/static/admin 符号链接存在时(Vue SPA 部署),/admin 端点会返回 SPA 入口 而非旧版 admin.html。此时直接读取 admin.html 文件内容进行测试。 """ resp = client.get("/admin") assert resp.status_code == 200 # 检测是否返回了 Vue SPA(无 section-overview 标记) if "section-overview" not in resp.text: # SPA 模式:直接读取旧版 admin.html 文件 from pathlib import Path admin_html_path = Path(__file__).resolve().parent.parent / "app" / "static" / "admin.html" return admin_html_path.read_text(encoding="utf-8") 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", "/api/v1/documents/upload-batch", "/api/v1/documents/tasks", ): 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 # Task 2 新增端点在真实路由集合内 for new_path in ( "/api/v1/documents/tasks", "/api/v1/documents/upload-batch", "/api/v1/documents/{doc_id}/reingest", ): assert new_path in route_paths, f"后端缺少 Task 2 新增端点: {new_path}" 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_progress_section(admin_html: str) -> None: """入库进度区块:导航按钮、section、表格结构、刷新按钮、空列表占位""" # section 与导航按钮(所有登录用户可见) assert 'id="section-progress"' in admin_html assert 'id="nav-progress"' in admin_html assert 'data-target="section-progress"' in admin_html assert "入库进度" in admin_html # 刷新按钮与状态统计 assert 'id="btn-refresh-progress"' in admin_html assert 'id="progress-stats"' in admin_html # 表格 tbody 与表头列:文件名 / 状态 / 创建时间 / 更新时间 / 操作 assert 'id="progress-tbody"' in admin_html for col in ("文件名", "状态", "创建时间", "更新时间", "操作"): assert col in admin_html # 空列表占位文案 assert "暂无入库任务" in admin_html def test_admin_page_progress_polling(admin_html: str) -> None: """入库进度区块:2s 轮询常量、startProgressPolling/stopProgressPolling 函数、GET /documents/tasks 路径""" # 进度区块专用轮询常量(2s) assert "PROGRESS_POLL_INTERVAL_MS" in admin_html assert "2000" in admin_html # 轮询启停函数 assert "function startProgressPolling" in admin_html assert "function stopProgressPolling" in admin_html assert "progressPollTimer" in admin_html # 数据来源:GET /documents/tasks?limit=50 assert "/api/v1/documents/tasks?limit=50" in admin_html # loadProgress / renderProgress / buildProgressRow 函数 assert "function loadProgress" in admin_html assert "function renderProgress" in admin_html assert "function buildProgressRow" in admin_html def test_admin_page_progress_polling_lifecycle(admin_html: str) -> None: """进度区块轮询生命周期:activateSection 切走暂停、切回恢复;showLogin 停止""" # activateSection 开头调用 stopProgressPolling(切走即暂停) assert "stopProgressPolling();" in admin_html # 切到 section-progress 时恢复轮询 assert 'targetId === "section-progress"' in admin_html assert 'startProgressPolling()' in admin_html # showLogin 退出时停止轮询 assert "stopProgressPolling" in admin_html def test_admin_page_progress_status_badge_reuse(admin_html: str) -> None: """进度表格状态徽章复用 INGEST_STATUS_TEXT 中文映射与 status-* 配色""" # statusBadgeEl 共享构造函数(无 id 可重复使用) assert "function statusBadgeEl" in admin_html # makeStatusBadge 复用 statusBadgeEl + 加 id assert "statusBadgeEl(status)" in admin_html # 状态徽章 CSS 类复用 for cls in ("status-running", "status-done", "status-failed"): assert cls in admin_html def test_admin_page_progress_actions(admin_html: str) -> None: """进度表格操作列:done 查看文档+重新入库、failed 重试、进行中无操作;reingest 端点""" # done 状态操作按钮 assert "查看文档" in admin_html assert "重新入库" in admin_html # failed 状态重试按钮 assert "重试" in admin_html # reingestDocument 函数与 POST /documents/{doc_id}/reingest 端点 assert "function reingestDocument" in admin_html assert "/reingest" in admin_html # 查看文档:切到 section-docs 并调用 loadDocDetail assert 'activateSection("section-docs")' in admin_html assert "loadDocDetail" in admin_html # 状态统计文案 assert "进行中" in admin_html def test_admin_page_batch_upload(admin_html: str) -> None: """批量上传:input multiple 属性、upload-batch 端点、批量结果展示、自动切进度区块""" # file input multiple 属性 assert 'id="upload-file"' in admin_html assert "multiple" in admin_html # 批量上传分支:files > 1 时走 upload-batch 端点 assert "/api/v1/documents/upload-batch" in admin_html assert 'fileInput.files.length > 1' in admin_html # FormData 用 files 字段逐文件 append assert 'batchForm.append("files"' in admin_html # 批量结果展示函数 assert "function renderBatchUploadResult" in admin_html assert "批量上传完成" in admin_html # 成功后自动切到入库进度区块 assert 'activateSection("section-progress")' in admin_html # 单文件上传仍走原 upload 端点(保持既有逻辑) assert "/api/v1/documents/upload\"" in admin_html or "/api/v1/documents/upload'," 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 '