Files
group_fqcd_jr/tests/unit/api/test_portal_frontend.py
T
lzf_0626 e096ffab22 修复投顾工作台白板:补上模块拆分时漏掉的 import
## 问题(合并进来的故障,不是本次会话改坏的)

合并 `origin/qyqy_develop`(f5d1b24 / 3134fe5)后 `pytest` 红了一条:
`test_advisor_dashboard_is_composed_from_feature_modules`。

查下去发现是**拆分做了一半**:

- 新建了 `advisor-config.js` / `actions-module.js` / `published-module.js`
- 把 `CONTENT_TYPE_LABELS`、`actionLabels`、`resultMessages` 从 `dashboard.js` 删掉了
- **但没有在 `dashboard.js` 里 import 它们**,`dashboard.js` 仍是拆分前的内联版本,
  第 39 行还在用 `CONTENT_TYPE_LABELS`

后果不只是测试红:投顾工作台一打开就 `ReferenceError: CONTENT_TYPE_LABELS is not defined`,
页面渲染不出来;同时那两个新模块是**死代码**(没有任何地方 import 它们)。
`index.html` 是单入口(只加载 `dashboard.js`),所以模块必须由它 import。

## 修法:把重构接完,而不是把测试改掉

- `dashboard.js` 变成薄组合层:挂 shell、取 DOM、组合两个模块,其余逻辑不再内联
- `advisor-config.js` 收拢 `GOAL_STATUS_LABELS` / `BOOK_STATUS_LABELS`(原来内联在 dashboard.js)
- `actions-module.js` 接管「目标确认与方案书」
- `published-module.js` 直接可用

⚠️ 关键点:`bind()` 会给**所有** `[data-action]` 按钮挂 `open()`,而 `actions-module.js`
原先不认识 `goal-status` —— 直接接线会让它掉到最后一行的兜底分支、被当成
「资产配置」发出去(点"目标确认与方案书"却收到一份配置建议)。
所以把「目标确认与方案书」一并做进 `open()` 的分支里,并在两处留了注释说明这个约束。

「目标确认与方案书」这条功能本身要保留:此前工作台只有 4 个"生成草案"操作 + 1 个只读列表,
而确认目标与查看方案书这两个端点**有接口没入口**,导致目标永远停在 `pending_confirmation`、
方案书永远停在 `pending`(实测客户 9001 正是如此)。

## 防回归:tools/check_portal_modules.py(新)

上面那个 bug **不能靠现有断言发现** —— 那些测试断言的是"某个字符串在文件里出现",
而这里的问题是"定义搬走了、使用处还在",浏览器里才炸,Python 测试全绿。

新检查做四件事:`node --check` 按 ES module 解析语法、相对 import 的目标文件存在、
import 的名字在目标文件里真有 `export`、**用到的全大写常量必须有来源**。

第 4 条是抓这个 bug 的关键。写的时候踩了两次坑,都已修正并记录在文件里:

1. 第一版用 `(?<![\w.$])` 排除属性访问、却把**模板字符串整体**当字符串剔除了 ——
   而 `CONTENT_TYPE_LABELS[row.content_type]` 恰好写在模板字符串里,
   于是漏报、检查全绿。现在只剔除单双引号字符串,模板字符串保留(`${}` 里是真代码)。
2. 用负面验证确认它真的有效:把 `published-module.js` 的 import 拿掉后,
   检查精确报出 `使用了 'CONTENT_TYPE_LABELS',但既没 import 也没在本文件声明`(exit 1);
   恢复后 exit 0。没有这一步,这个检查就是个摆设。

同时接进测试:`test_portal_feature_modules_have_consistent_imports` 调用它,
保证以后每次 `pytest tests/unit` 都会执行。

## 实测

- `pytest tests/unit tests/contract` -> **1400 passed, 2 skipped, 0 failed**
  (合并后未修时是 1399 passed + 1 failed)
- `pytest tests/unit/api/test_portal_frontend.py` -> 39 passed(38 + 新增 1 条)
- `python tools/check_portal_modules.py` -> 全部通过;负面验证 exit 1
- `ruff check app tests tools alembic hq.py` -> All checks passed
2026-09-14 02:07:44 +08:00

347 lines
14 KiB
Python

from __future__ import annotations
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_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_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_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_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