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