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