Files
group_fqcd_jr/tests/unit/api/test_portal_frontend.py
T
张胜宇 e5b4d02b0d merge: 集成投顾组 3 个提交(解除与「投顾模块清除」的冲突)+ 客服 Agent 重构收口
## 为什么要合并
远端 `origin/qyqy_develop` 领先 3 个提交(`5607751` / `2fe7d0c` / `74b7d00`:投顾需求与架构文档、
客户主动申报投顾方案 + 受理自动出草稿、方案交付落点与推荐依据 LLM 增强),而本地 `5d0becb`
按 `D4.4` / `D4.5`(CS-PURGE-2026-012/013)把投顾模块整体清除了。**两个目标不可兼得**:
远端新代码反向 import 已被清除的模块(`app.model.investment_goal`、
`app.service.product_recommendation_service`、`app.service.advisor_rollout_service`),
强行推进只会让两边都跑不起来。

**裁定:投顾组的新功能 > 本地的投顾清除。** 依据是 `D4.4` §0-②③ 自己写下的风险
——按名字清投顾会同时拆掉产品数据底座与 MVP 硬阻断,并失去"改 6 个底座文件时的对照组"。
本次合并因此**恢复投顾模块**;就代码面而言,`D4.4` / `D4.5` 的清除结果被本次合并取代
(留痕见 `开发文档\D1.6` §4.37)。

## 冲突怎么解的(12 处)
- **8 处 modify/delete 取远端**:`recommendations.py` / `product_recommendation_service.py` /
  `employee-advisor/dashboard/{actions-module.js,dashboard.css,dashboard.js,index.html}` /
  `tools/{check_portal_modules.py,grant_advisor_role.py}` —— 即"我删、远端改",保留投顾文件。
- **3 处内容冲突取远端**:`app/main.py`(投顾 import 与 `include_router`)、
  `common/api-client.js`(投顾端点表)、`tests/unit/api/test_portal_frontend.py`(4 条投顾前端契约)。
- **1 处取远端 + 保留我方**:`app/main.py` 解除冲突的同时,保留本轮的
  `/customer-service-test` 挂载移除(该联调页与用例已随重构作废)。

## 因"取消清除"而必须回滚的语义改动(否则恢复出来的投顾代码跑不动)
- `app/service/agent/bootstrap.py`:恢复 `AdvisorAgent` 与 5 个投顾工具注册
  (`query_investment_goal` / `analyze_portfolio` / `generate_asset_allocation` /
  `recommend_products` / `compare_products`);客服 Agent 注释按本轮口径保留。
- `app/core/config.py`:恢复 `advisor_rollout_enabled` / `advisor_rollout_customer_ids`。
- `app/static/portal/common/layout/app-shell.js`:恢复投顾工作台导航与 `advisor` 角色名。
- `tools/seed_test_rbac.py`:恢复"admin 取全量元组"的授权模型(保留远端新增的
  9070-9074 权限码与客户侧 9071/9072 绑定)。
- `tools/portal_api_check.py`:恢复投顾实测用例(AD003/AD005/AD011/A047 与 `advisor_t` 登录),
  并**新增判定**:被渲染的集合为空(0 条)时判 `SKIP` 而不是 `FAIL`
  —— "没有行"与"字段没带"是两回事,混报会把排查方向带偏。
- `app/static/portal/common/api-client.js`:以远端为基准,重新叠加本轮的
  **访客令牌 `Authorization` 优先**修复(浮窗访客身份稳定性)。

## 数据库夹具同步(代码恢复 ⇒ 夹具也要恢复)
- `tools/grant_advisor_role.py`:新建 `advisor` 角色并授权(实测 34 项权限)。
- `tools/create_test_user.py --id 9020 --username advisor_t --role advisor`:重建演示账号。

## 集成期发现并修掉的过期断言
- `tests/unit/test_advisor_migration_contract.py`:alembic 末端钉死值仍是
  `20260914_baseline_auto_increment`,而远端新增了 `20260916_advisor_service_request`
  ⇒ 这条断言**在远端分支上本身就是红的**。本次把它更新到新末端并补了注释。

## 验证(本机实测)
| 门禁 | 结果 |
|---|---|
| `pytest -q`(全量,含集成) | **1909 passed / 3 skipped / 0 failed** |
| `ruff check app tools tests` | 20(远端分支 22,本地仅客服线基线 19) |
| `mypy app` | 2(= 既有基线) |
| `tools/portal_api_check.py` | 40 项:通过 35 / 失败 0 / 跳过 5 |
| `tools/e2e_smoke_test.py --read-only` | 31/31 |
| `_eval_harness/http_probe.py` | 11/11 succeeded |
| `_consistency.py` | GATE PASS |
| `_fe_boundary_http.py`(前端入参边界真机) | 全部符合预期 |

## 未做(如实登记)
- **投顾演示数据未灌**:`AD011` / `A047` 需要 `advisor_product_suitability_reference`
  这类带 `source_url` + `document_sha256` 的证据行,而披露文件不在仓库里;
  `tools/seed_advisor_demo.py` 明确"不编证据"(fail closed),故这两条按空集 SKIP。
- **客服线文档目录仍未入库**:`客服agent/`、`开发文档/`(权威副本在本机)与
  `_chunks_report.txt`(本地构建产物)依旧排除在提交之外。
2026-09-20 14:52:35 +08:00

554 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import re
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 '<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_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` 就这么写过:合并时
两个分支各自把同一行的版本号换成新的,两边都被保留,成了一条重复的 `<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
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_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
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
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