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 '' 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` 就这么写过:合并时
两个分支各自把同一行的版本号换成新的,两边都被保留,成了一条重复的 `