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

554 lines
24 KiB
Python
Raw Normal View History

2026-09-13 15:56:54 +08:00
from __future__ import annotations
import re
2026-09-13 15:56:54 +08:00
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/advisor-plans/",
2026-09-13 15:56:54 +08:00
"/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"]
2026-09-14 18:13:10 +08:00
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
2026-09-13 15:56:54 +08:00
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
2026-09-13 15:56:54 +08:00
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_HISTORY", "ADVISOR_GOAL", "ADVISOR_ANALYSIS",
"ADVISOR_ALLOCATION", "ADVISOR_RECOMMEND", "ADVISOR_CREATE_GOAL",
"ADVISOR_REVIEW_RECOMMENDATION", "ADVISOR_PUBLISH_RECOMMENDATION",
"ADVISOR_DELETE_RECOMMENDATION",
):
assert f"{endpoint_id}:" in source
for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标", "历史方案记录"):
assert label in dashboard
def test_advisor_plan_view_is_shared_by_both_surfaces() -> None:
"""推荐方案的可视化渲染必须**只有一份**,投顾工作台与客户页共用。
两处都是同一份 `advisor_recommendation_plan`,各写一套必然漂移
(改了一边忘了另一边)。这条测试同时守住「共享模块存在」与「两处都在用它」。
"""
view = (PORTAL / "common" / "advisor-plan-view.js").read_text(encoding="utf-8")
assert "export function renderPlanProducts" in view
assert "export async function hydratePlanView" in view
assert "P002" in view
for page in (
"employee-advisor/dashboard/actions-module.js",
"customer/advisor-plans/advisor-plans.js",
):
source = (PORTAL / page).read_text(encoding="utf-8")
assert "advisor-plan-view.js" in source, page
def test_advisor_history_module_exposes_plan_actions() -> None:
"""历史方案记录里,推荐方案卡片要带审核/驳回/发送/删除四个操作。
四个动作对应三个端点(审核与驳回共用一个 reviews 端点,用 `decision` 区分)。
少了 `ADVISOR_DELETE_RECOMMENDATION` 就只剩"能看不能删"。
"""
source = (
PORTAL / "employee-advisor" / "dashboard" / "history-module.js"
).read_text(encoding="utf-8")
for label in ("审核通过", "驳回", "发送给客户", "删除"):
assert label in source
for endpoint in (
"ADVISOR_REVIEW_RECOMMENDATION",
"ADVISOR_PUBLISH_RECOMMENDATION",
"ADVISOR_DELETE_RECOMMENDATION",
):
assert endpoint in source
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 "./history-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
2026-09-13 21:08:57 +08:00
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
2026-09-13 15:56:54 +08:00
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
2026-09-13 23:19:52 +08:00
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
2026-09-13 23:19:52 +08:00
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
2026-09-13 23:19:52 +08:00
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
2026-09-14 10:40:41 +08:00
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
2026-09-13 15:56:54 +08:00
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
2026-09-13 15:56:54 +08:00
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
def test_customer_service_widget_is_mounted_by_the_shell_for_public_and_customer_modes() -> None:
"""浮窗由 `mountShell()` **单点挂载**,且只挂公开页与客户工作台。
挂载点选在 shell 而不是九个页面的 JS:写九份就意味着九处 `?v=` 版本号要一起改,
漏一个就是"某个页面浮窗样式陈旧"。员工四类工作台(risk / advisor / operator / admin)
刻意不挂 —— 它们各自有业务 Agent,挂上只会让"当前账号能不能用这个入口"变成
一道需要解释的问题。
"""
shell = (PORTAL / "common" / "layout" / "app-shell.js").read_text(encoding="utf-8")
widget = PORTAL / "common" / "customer-service-widget"
assert (widget / "widget.js").is_file()
assert (widget / "widget.css").is_file()
import_line = (
"import { mountCustomerServiceWidget } from "
"'/static/portal/common/customer-service-widget/widget.js';"
)
assert import_line in shell
assert "const CUSTOMER_SERVICE_MODES = Object.freeze(['public', 'customer']);" in shell
assert "if (!CUSTOMER_SERVICE_MODES.includes(mode)) return;" in shell
assert "mountCustomerServiceFor(mode);" in shell
assert "'/static/portal/common/customer-service-widget/widget.css?v=" in shell
def test_customer_service_widget_reuses_shared_visitor_token_and_endpoint_table() -> None:
"""浮窗不得自带第二份访客令牌实现,也不得绕过端点表直接 `fetch`。"""
source = (
PORTAL / "common" / "customer-service-widget" / "widget.js"
).read_text(encoding="utf-8")
assert "common/visitor-token.js" in source
assert "common/api-client.js" in source
assert "visitorHeaders()" in source
# 访客令牌只应有 `visitor-token.js` 一份实现:存储 key 与 JWT 解析都不该出现在这里。
assert "portalVisitorToken" not in source
assert "sessionStorage" not in source
assert "atob(" not in source
# 所有请求走端点表(`test_business_pages_do_not_call_fetch_directly` 的同一口径)。
assert "fetch(" not in source
for endpoint_id in ("'C001'", "'R001'", "'R002'", "'C005'"):
assert endpoint_id in source, endpoint_id
# 轮询预算要覆盖 Worker 的整条链路(实测 4.1–4.8 秒),不得退回"几次就放弃"。
assert "const POLL_ATTEMPTS = 40;" in source
def test_customer_service_widget_does_not_show_tool_names_as_sources() -> None:
"""`result.source_references` 目前只有 `tool` 类型(标题就是工具名)。
知识来源引用是 `C-10` 乙的**降级项**:治理层不认可 `knowledge` 来源,一旦输出会让
整个 run 失败。所以浮窗**刻意不渲染「参考:」行** —— 显示「参考:query_knowledge」
对客户毫无意义。可追溯性由审计承接。此断言防止有人"顺手"把它加回来。
"""
source = (
PORTAL / "common" / "customer-service-widget" / "widget.js"
).read_text(encoding="utf-8")
css = (
PORTAL / "common" / "customer-service-widget" / "widget.css"
).read_text(encoding="utf-8")
assert "snapshot.result?.source_references" not in source
assert "addReferences" not in source
assert "cs-widget__references" not in source
assert "cs-widget__references" not in css
assert "C-10" in source # 降级理由留痕,不能只删代码不写原因
def test_api_client_lets_callers_pin_an_explicit_bearer_token() -> None:
"""调用方显式传入的 `Authorization` 优先于自动附加的登录令牌。
否则"带访客令牌取公开数据"会在浏览器恰好有登录令牌时静默变成"用登录身份取数据",
症状是同一个公开页对访客与已登录用户显示不同内容(`README.md` 明令禁止混用)。
"""
source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8")
assert "const callerAuth = options.headers?.Authorization;" in source
assert "if (endpoint.auth !== false && token && !callerAuth) {" in source