Files
group_fqcd_jr/tests/unit/api/test_portal_frontend.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

416 lines
17 KiB
Python

from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
import httpx
import pytest
from app.main import create_app
ROOT = Path(__file__).resolve().parents[3]
PORTAL = ROOT / "app" / "static" / "portal"
@pytest.mark.asyncio
async def test_portal_root_redirects_to_public_home() -> None:
transport = httpx.ASGITransport(app=create_app())
async with httpx.AsyncClient(
transport=transport, base_url="http://test", follow_redirects=False
) as client:
response = await client.get("/")
assert response.status_code in {302, 307}
assert response.headers["location"] == "/portal/guest/home/"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path",
[
"/portal/guest/home/",
"/portal/guest/products/",
"/portal/guest/product-detail/?code=510300",
"/portal/customer/login/",
"/portal/customer/dashboard/",
"/portal/customer/holdings/",
"/portal/customer/profit-loss/",
"/portal/customer/orders/",
"/portal/customer/transactions/",
"/portal/customer/cash-ledger/",
"/portal/customer/risk-questionnaire/",
"/portal/employee-console/login/",
"/portal/employee-console/workspace/",
"/portal/employee-risk/dashboard/",
],
)
async def test_public_portal_pages_are_served(path: str) -> None:
transport = httpx.ASGITransport(app=create_app())
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(path)
assert response.status_code == 200
assert 'lang="zh-CN"' in response.text
assert '<meta charset="UTF-8">' in response.text
def test_every_portal_page_has_local_js_and_css_entry() -> None:
pages = list(PORTAL.glob("*/*/index.html"))
assert pages
for page in pages:
page_name = page.parent.name
assert (page.parent / f"{page_name}.js").is_file(), page
assert (page.parent / f"{page_name}.css").is_file(), page
def test_business_pages_do_not_call_fetch_directly() -> None:
direct_fetch_files = [
path.relative_to(PORTAL).as_posix()
for path in PORTAL.rglob("*.js")
if "fetch(" in path.read_text(encoding="utf-8")
]
assert direct_fetch_files == ["common/api-client.js"]
def test_nl2sql_page_separates_query_values_and_generated_sql() -> None:
html = (PORTAL / "employee-operations" / "nl2sql" / "index.html").read_text(
encoding="utf-8"
)
source = (PORTAL / "employee-operations" / "nl2sql" / "nl2sql.js").read_text(
encoding="utf-8"
)
assert 'data-view="general"' in html
assert "最终查询结果" in source
assert "AI 生成的 SQL" in source
assert "run?.result?.sql" in source
assert "data?.rows" in source
assert "运行编号" not in source
assert "错误码" not in source
def test_promotion_page_uses_resizable_uniform_fields_and_cache_busting() -> None:
html = (PORTAL / "employee-operations" / "promotion" / "index.html").read_text(
encoding="utf-8"
)
css = (PORTAL / "employee-operations" / "promotion" / "promotion.css").read_text(
encoding="utf-8"
)
source = (
PORTAL / "employee-operations" / "promotion" / "promotion.js"
).read_text(encoding="utf-8")
assert "promotion-page" in html
assert "promotion-attachments-grid" in html
assert "promotion.css?v=20260914-layout2" in html
assert "promotion.js?v=20260914-layout2" in html
assert "height: 72px" in css
assert "resize: vertical" in css
assert "justify-content: center" in css
assert "autosizeTextarea" not in source
assert "function validateFormats(formats)" in source
assert "最多选择两种输出格式" in source
assert "validateFormats(selectedFormats())" in source
api_source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8")
assert (
"PROMOTION_GENERATE: { method: 'POST', "
"path: '/api/v1/fund-promotion-materials/{taskNo}/generations', "
"idempotent: true, timeout: 120000 }"
) in api_source
def test_nl2sql_page_has_aligned_workspace_spacing_and_cache_busting() -> None:
html = (PORTAL / "employee-operations" / "nl2sql" / "index.html").read_text(
encoding="utf-8"
)
css = (PORTAL / "employee-operations" / "nl2sql" / "nl2sql.css").read_text(
encoding="utf-8"
)
assert "nl2sql-page" in html
assert "nl2sql.css?v=20260914-layout3" in html
assert "nl2sql.js?v=20260914-layout3" in html
assert "padding: 28px 32px 32px" in css
assert "gap: 32px" in css
assert "resize: vertical" in css
assert "padding: 20px" in css
assert "border-radius: var(--radius-md)" in css
def test_api_client_registers_all_trading_endpoint_ids() -> None:
source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8")
for endpoint_id in ("T001", "T002", "T003", "T004", "T005", "T006", "T007", "T008", "T009"):
assert f"{endpoint_id}:" in source
def test_product_detail_preserves_customer_session_for_trade_entry() -> None:
html = (PORTAL / "guest" / "product-detail" / "index.html").read_text(encoding="utf-8")
source = (PORTAL / "guest" / "product-detail" / "product-detail.js").read_text(
encoding="utf-8"
)
dashboard = (PORTAL / "customer" / "dashboard" / "dashboard.js").read_text(
encoding="utf-8"
)
assert 'data-trade-action' in html
assert "getAuthContext" in source
assert "textContent = '进入交易'" in source
assert "action=trade" in source
assert "productInput.value = productCode" in dashboard
def test_api_client_registers_onboarding_risk_and_admin_endpoints() -> None:
source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8")
for endpoint_id in (
"ONB001", "ONB002", "RK001", "RK002", "RK003", "RK004", "RK005",
"RK006", "RK007", "RK008", "RK009", "RK010", "RK011", "RK012",
"RK013", "RK014", "RK015", "A002", "A003", "A004", "A005", "A006",
"A012", "A033", "A035", "A036", "A037", "A038", "A039", "A040",
):
assert f"{endpoint_id}:" in source
def test_no_portal_page_includes_the_same_script_twice() -> None:
"""同一个入口 JS 被引两次(哪怕 `?v=` 不同)会让页面出现两份顶部导航。
浏览器按**完整 URL** 去重:`x.js?v=A` 与 `x.js?v=B` 是两个模块、**各执行一次**。
入口里的 `mountShell()` 于是跑两遍,插入两份 header / footer ——
2026-09-14 `employee-console/workspace/index.html` 就这么写过:合并时
两个分支各自把同一行的版本号换成新的,两边都被保留,成了一条重复的 `<script>`。
"""
pattern = re.compile(r"<script[^>]*\ssrc=[\"']([^\"']+)[\"']", re.IGNORECASE)
duplicated: list[str] = []
for page in sorted(PORTAL.rglob("*.html")):
# 只比 `<script>`;站内绝对路径去掉 query 再归并
sources = [
url.split("?", 1)[0] if url.startswith("/") else url
for url in pattern.findall(page.read_text(encoding="utf-8"))
]
repeated = sorted({src for src in sources if sources.count(src) > 1})
if repeated:
duplicated.append(f"{page.relative_to(PORTAL)}: {repeated}")
assert not duplicated, f"同一入口脚本被引入多次:{duplicated}"
def test_mount_shell_is_idempotent() -> None:
"""`mountShell` 要自带「已经挂过就不再挂」的保护。
上一条测试守住 HTML,这一条守住代码 —— 两侧都挡一道,
因为这个 bug 的症状很难反推原因(页面看起来只是"多了一块"),
而以后加缓存版本号时很容易再犯。
"""
source = (PORTAL / "common" / "layout" / "app-shell.js").read_text(encoding="utf-8")
assert "if (document.querySelector('.site-header')) return;" in source
def test_risk_scan_endpoint_uses_extended_timeout() -> None:
source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8")
assert (
"RK006: { method: 'POST', path: '/api/v1/risk/alerts/scan', "
"idempotent: true, timeout: 60000 }" in source
)
assert "options.timeout || endpoint.timeout || 8000" in source
def test_customer_questionnaire_uses_server_contract() -> None:
source = (PORTAL / "customer" / "risk-questionnaire" / "risk-questionnaire.js").read_text(
encoding="utf-8"
)
assert "ONB001" in source
assert "ONB002" in source
assert "declaration_accepted: true" in source
assert "total_score" not in source
def test_questionnaire_is_customer_only_and_auth_context_matches_token() -> None:
auth = (PORTAL / "common" / "auth.js").read_text(encoding="utf-8")
login = (PORTAL / "common" / "login-controller.js").read_text(encoding="utf-8")
questionnaire = (
PORTAL / "customer" / "risk-questionnaire" / "risk-questionnaire.js"
).read_text(encoding="utf-8")
assert "export function requireCustomerOnly()" in auth
assert "IDENTITY_COOKIE = 'portal_auth_user'" in auth
assert "readCookie(IDENTITY_COOKIE) === String(context.userId)" in auth
assert "requireCustomerOnly()" in questionnaire
assert "? ['customer']" in login
def test_portal_auth_supports_cross_tab_logout_and_account_switching() -> None:
auth = (PORTAL / "common" / "auth.js").read_text(encoding="utf-8")
shell = (PORTAL / "common" / "layout" / "app-shell.js").read_text(
encoding="utf-8"
)
login = (PORTAL / "common" / "login-controller.js").read_text(
encoding="utf-8"
)
assert "new BroadcastChannel(AUTH_CHANNEL_NAME)" in auth
assert "CONTEXT_COOKIE = 'portal_auth_context'" in auth
assert "readSessionContext() || readSharedContext()" in auth
assert "signed-in" in auth
assert "signed-out" in auth
assert "account-switched" in auth
assert "postMessage({ type })" in auth
assert "type === 'signed-in'" in auth
assert "hasMatchingIdentity(readCookie('auth_token'), context)" in auth
broadcast_line = next(line for line in auth.splitlines() if "postMessage" in line)
assert "access_token" not in broadcast_line
assert "data-switch-account" in shell
assert "data-logout" in shell
assert "切换账号" in shell
assert "退出登录" in shell
assert "REASON_MESSAGES" in login
def test_risk_workspace_covers_documented_modules() -> None:
html = (PORTAL / "employee-risk" / "dashboard" / "index.html").read_text(encoding="utf-8")
source = (PORTAL / "employee-risk" / "dashboard" / "dashboard.js").read_text(encoding="utf-8")
permissions = (PORTAL / "common" / "permission-codes.js").read_text(encoding="utf-8")
for label in ("预警队列", "证据查询", "通知记录", "风控助手", "风险日报"):
assert label in html
combined = html + source + permissions
for permission in (
"risk:alert:read",
"risk:alert:write",
"risk:alert:scan",
"risk:report:mail",
):
assert permission in combined
def test_risk_workspace_has_context_sessions_system_tips_and_expandable_evidence() -> None:
html = (PORTAL / "employee-risk" / "dashboard" / "index.html").read_text(encoding="utf-8")
source = (PORTAL / "employee-risk" / "dashboard" / "dashboard.js").read_text(encoding="utf-8")
assert "data-chat-context" in html
assert "data-clear-chat-context" in html
assert "data-alert-prompts" in html
assert "data-system-tips" in html
assert "data-system-tip-count" in html
assert "data-open-notification-records" in html
assert "站内提醒" in html
assert "data-policy-tips" in html
assert "data-report-preview" in html
assert "chatContextKey" in source
assert "session_id: sessionId" in source
assert "session_id: crypto.randomUUID()" not in source
assert "bindExpandableRows" in source
assert "bindAlertContext" in source
assert "isStationNotification" in source
assert "fetchStationNotifications" in source
assert "refreshSystemTipCount" in source
assert "actionDialog.close();" in source
assert "alertDialog.close();" in source
assert "reportDialog.close();" in source
assert "5000" in source
assert "证据归档" in source
assert "大模型生成" in source
def test_risk_evidence_filters_remove_time_inputs_and_use_business_labels() -> None:
html = (PORTAL / "employee-risk" / "dashboard" / "index.html").read_text(encoding="utf-8")
source = (PORTAL / "employee-risk" / "dashboard" / "dashboard.js").read_text(encoding="utf-8")
assert 'name="start_time"' not in html
assert 'name="end_time"' not in html
assert "data-behavior-filter" in html
assert "FIELD_LABELS" in source
assert "技术字段" in source
assert "data-table__expandable-row" in source
assert "row.addEventListener('click'" in source
assert "row.addEventListener('keydown'" in source
def test_risk_evidence_snapshot_uses_business_labels_and_nested_sections() -> None:
source = (PORTAL / "employee-risk" / "dashboard" / "dashboard.js").read_text(encoding="utf-8")
css = (PORTAL / "employee-risk" / "dashboard" / "dashboard.css").read_text(encoding="utf-8")
assert "SNAPSHOT_FIELD_LABELS" in source
assert "renderEvidenceSnapshot" in source
assert "renderBusinessSection" in source
assert "businessRecordMarkup" in source
assert "关联工单" in source
assert "renderMergedEvidence" in source
assert "renderEvidenceArchive" in source
assert "查看原始数据" in source
assert "evidence-snapshot__nested" in css
assert "evidence-snapshot__grid" in css
assert "business-record__grid" in css
def test_risk_alert_action_is_first_column() -> None:
source = (PORTAL / "employee-risk" / "dashboard" / "dashboard.js").read_text(encoding="utf-8")
assert "actionFirst" in source
assert "actionFirst: true" in source
def test_risk_tables_show_page_and_total_summary() -> None:
html = (PORTAL / "employee-risk" / "dashboard" / "index.html").read_text(encoding="utf-8")
source = (PORTAL / "employee-risk" / "dashboard" / "dashboard.js").read_text(encoding="utf-8")
for summary_id in ("alert", "evidence", "notification"):
assert f"data-{summary_id}-summary" in html
assert "page_size" in source
assert "totalPages" in source
assert "共 ${total} 条" in source
def test_risk_alert_queue_uses_ten_rows_per_page() -> None:
source = (
PORTAL / "employee-risk" / "dashboard" / "dashboard.js"
).read_text(encoding="utf-8")
assert "limit: 10" in source
assert "meta.page_size ?? 10" in source
def test_risk_alert_prompts_hide_until_alert_context_is_bound() -> None:
html = (PORTAL / "employee-risk" / "dashboard" / "index.html").read_text(encoding="utf-8")
css = (PORTAL / "employee-risk" / "dashboard" / "dashboard.css").read_text(encoding="utf-8")
source = (PORTAL / "employee-risk" / "dashboard" / "dashboard.js").read_text(encoding="utf-8")
assert 'data-alert-prompts hidden' in html
assert ".risk-prompts[hidden]" in css
assert "display: none !important" in css
assert "document.querySelector('[data-alert-prompts]').hidden = !alertNo" in source
def test_admin_workspace_is_not_an_identity_placeholder() -> None:
html = (PORTAL / "employee-console" / "workspace" / "index.html").read_text(encoding="utf-8")
assert "只展示服务端确认的身份边界" not in html
for label in ("角色与权限", "配置与模型", "审计记录", "转人工工单", "画像候选"):
assert label in html
def test_admin_workspace_rule_submit_closes_before_next_function() -> None:
source = (
PORTAL / "employee-console" / "workspace" / "workspace.js"
).read_text(encoding="utf-8")
assert (
" } finally { submit.disabled = false; }\n"
" }\n\n"
" async function loadDriftReviews() {"
) in source
def test_frontend_has_no_remote_scripts_or_token_local_storage() -> None:
sources = "\n".join(
path.read_text(encoding="utf-8")
for path in PORTAL.rglob("*")
if path.is_file() and path.suffix in {".html", ".js", ".css"}
)
assert '<script src="http' not in sources
assert "localStorage.setItem('token'" not in sources
assert "console.log" not in sources
def test_shared_state_view_has_distinct_authentication_and_permission_states() -> None:
source = (PORTAL / "common" / "state-view.js").read_text(encoding="utf-8")
assert "ERR_CODES.AUTHENTICATION_REQUIRED" in source
assert "ERR_CODES.AGENT_PERMISSION_DENIED" in source
assert "登录状态已失效" in source
assert "当前账户暂不可访问" in source
def test_protected_api_unauthorized_response_clears_shared_session() -> None:
source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8")
assert "clearAuthSession, getAccessToken" in source
assert "response.status === 401 && endpoint.auth !== false" in source
def test_public_home_uses_a_local_hero_image() -> None:
home = (PORTAL / "guest" / "home" / "index.html").read_text(encoding="utf-8")
image = PORTAL / "guest" / "home" / "assets" / "wealth_architecture_hero.jpg"
assert "/static/portal/guest/home/assets/wealth_architecture_hero.jpg" in home
assert image.is_file()
assert image.stat().st_size > 100_000