Files
group_fqcd_jr/tests/unit/api/test_portal_frontend.py
T

462 lines
19 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_advisor_workspace_registers_documented_operation_endpoints() -> None:
source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8")
dashboard = (PORTAL / "employee-advisor" / "dashboard" / "index.html").read_text(
encoding="utf-8"
)
for endpoint_id in (
"ADVISOR_PUBLISHED", "ADVISOR_GOAL", "ADVISOR_ANALYSIS",
"ADVISOR_ALLOCATION", "ADVISOR_RECOMMEND", "ADVISOR_CREATE_GOAL",
):
assert f"{endpoint_id}:" in source
for label in ("组合分析", "资产配置", "生成推荐草案", "录入客户目标"):
assert label in dashboard
def test_advisor_dashboard_is_composed_from_feature_modules() -> None:
source = (PORTAL / "employee-advisor" / "dashboard" / "dashboard.js").read_text(
encoding="utf-8"
)
assert "./actions-module.js" in source
assert "./published-module.js" in source
config = (PORTAL / "employee-advisor" / "dashboard" / "advisor-config.js").read_text(
encoding="utf-8"
)
assert "ACTION_LABELS" in config
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_portal_feature_modules_have_consistent_imports() -> None:
"""拆分前端模块时最容易漏 import:定义搬走了,使用处却留在原文件。
这类问题**上面那些字符串断言全都看不见** —— 只会在浏览器里以
`ReferenceError: XXX is not defined` 爆出来,表现为"投顾工作台打开是白板",
而 Python 测试一片绿。2026-09-13 合并进来的提交就真的发生了:
`dashboard.js` 还在用已经搬进 `advisor-config.js` 的 `CONTENT_TYPE_LABELS`。
检查逻辑在 `tools/check_portal_modules.py`(语法 + import 可解析 + 常量有来源),
这里只是把它接进测试,保证以后每次跑测试都会执行到。
"""
result = subprocess.run(
[sys.executable, str(ROOT / "tools" / "check_portal_modules.py")],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, f"{result.stdout}\n{result.stderr}"
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