## 问题(合并进来的故障,不是本次会话改坏的)
合并 `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
347 lines
14 KiB
Python
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
|