Files
xiaobai-review/xiaobai-datahub/tests/test_site_console.py
T
总工andmultica-agent fa82b8621e feat(HEL-566): 去掉主站行情管理入口并修好模型列表收起
主站行情管理并入数据中枢
- 数据中枢数据源配置页新增「主站行情任务」卡:交易时段后台刷新开关、
  手动后台刷新、历史区间回补(任务提交 + 轮询主站 job 记录亮灯),
  数据与调度仍归主站,控制台只经桥接代操作
- 新增桥接端点 /api/hub-admin/market/{refresh,backfill};回补改为
  market.backfill 后台任务(jobs.config.json 注册,锁独立,超时 30 分钟),
  桥接调用秒回,不再阻塞
- 主站桌面端删除顶栏「行情管理」按钮与整个行情管理对话框,清理随之
  失效的 CSS;移动端删除 system/admin 页与入口,管理员专区只留数据中枢
- Tushare Token / iFinD 凭证编辑沿用数据源页既有凭证区,无功能缺失

模型池收起修复
- 拉出模型清单后按钮切换为「收起列表」,收起只留一行摘要;再次点击
  重新拉取并展开;勾选添加完成后清单自动收起(原逻辑保留)
- 交互全部沿用 HEL-558 已确认样式的既有按钮与提示组件,未新增视觉

自测
- verify_baseline 通过;pytest 492 项通过;数据中枢 240 项通过
- verify_datahub_console 新增 [11b] 行情任务桥接端到端段;UI 自测新增
  行情任务卡开关往返、模型清单展开/收起/再展开/添加自动收起,日夜主题
  与 1030 窄屏复验通过
- Playwright e2e 102/103:唯一失败项在基线提交上同样失败(本机字体度量
  导致的头部溢出,与本卡无关)

Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 17:24:05 +08:00

268 lines
12 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
import unittest
from http import HTTPStatus
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
REVIEW_ROOT = ROOT.parent
from datahub.serving import ApiError
from datahub.siteauth import SiteBridgeError
from datahub.siteauth import SiteBridgeError
from datahub.siteconsole import SiteConsole
class RecordingBridge:
"""Stands in for the review site so these tests exercise only the mapping."""
def __init__(self, replies: dict[str, dict[str, Any]] | None = None) -> None:
self.replies = replies or {}
self.calls: list[tuple[str, dict[str, Any]]] = []
self.fail_with = ""
def call(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
self.calls.append((path, dict(payload or {})))
if self.fail_with:
raise SiteBridgeError(self.fail_with)
return self.replies.get(path, {})
def paths(self) -> list[str]:
return [path for path, _ in self.calls]
STATUS = {
"llm": {
"primary_model_id": "gpt-main",
"fallback_model_id": "",
"models": [
{"id": "gpt-main", "name": "GPT 主力", "model": "gpt-4o", "base_url": "https://api.openai.com/v1", "configured": True, "api_key_last4": "7f21"},
{"id": "gpt-mini", "name": "GPT 轻量", "model": "gpt-4o-mini", "base_url": "https://api.openai.com/v1", "configured": True, "api_key_last4": "7f21"},
{"id": "self-host", "name": "自建 Qwen", "model": "qwen2.5", "base_url": "https://llm.intra.example.com/v1", "configured": False, "api_key_last4": ""},
],
}
}
class ModelPoolTests(unittest.TestCase):
def setUp(self) -> None:
self.bridge = RecordingBridge({"/api/hub-admin/status": STATUS})
self.console = SiteConsole(self.bridge)
def test_models_are_grouped_per_vendor_so_one_vendor_can_hold_many(self) -> None:
payload = self.console.models()
groups = {group["base_url"]: group for group in payload["groups"]}
self.assertEqual(2, len(groups))
openai = groups["https://api.openai.com/v1"]
self.assertEqual("OpenAI", openai["label"])
self.assertEqual(2, len(openai["models"]))
self.assertEqual("7f21", openai["key_last4"])
self.assertTrue(openai["configured"])
def test_unknown_vendors_fall_back_to_their_host_as_a_label(self) -> None:
groups = {group["base_url"]: group for group in self.console.models()["groups"]}
self.assertEqual("llm.intra.example.com", groups["https://llm.intra.example.com/v1"]["label"])
self.assertFalse(groups["https://llm.intra.example.com/v1"]["configured"])
def test_vendor_presets_are_offered_for_the_new_vendor_picker(self) -> None:
vendors = self.console.models()["vendors"]
self.assertIn("OpenAI", [vendor["label"] for vendor in vendors])
self.assertTrue(all(vendor["base_url"].startswith("http") for vendor in vendors))
def test_saving_only_forwards_the_keys_the_caller_actually_sent(self) -> None:
self.console.save_models({"primary_model_id": "gpt-mini"})
path, payload = self.bridge.calls[0]
self.assertEqual("/api/hub-admin/settings/save", path)
self.assertEqual({"primary_model_id": "gpt-mini"}, payload)
def test_saving_nothing_is_refused_rather_than_wiping_the_pool(self) -> None:
with self.assertRaises(ApiError):
self.console.save_models({})
self.assertEqual([], self.bridge.paths())
def test_fetching_a_model_list_needs_a_vendor_endpoint(self) -> None:
with self.assertRaises(ApiError):
self.console.fetch_models({"api_key": "sk-test"})
def test_fetch_passes_the_typed_key_through_for_first_time_vendors(self) -> None:
self.bridge.replies["/api/hub-admin/models/fetch"] = {"models": ["gpt-4o", "gpt-4o-mini"]}
result = self.console.fetch_models({"base_url": "https://api.openai.com/v1", "api_key": "sk-new"})
self.assertEqual(["gpt-4o", "gpt-4o-mini"], result["models"])
self.assertEqual({"base_url": "https://api.openai.com/v1", "api_key": "sk-new"}, self.bridge.calls[0][1])
def test_a_site_outage_becomes_a_console_error_not_a_traceback(self) -> None:
self.bridge.fail_with = "主站不可达"
with self.assertRaises(ApiError) as caught:
self.console.models()
self.assertIn("主站不可达", str(caught.exception))
class MemberAndQuotaTests(unittest.TestCase):
def setUp(self) -> None:
self.bridge = RecordingBridge({"/api/hub-admin/members": {"users": [{"id": 2}], "membership": {"member_daily_limit": 50}}})
self.console = SiteConsole(self.bridge)
def test_saving_a_member_reads_the_roster_back_so_the_page_shows_truth(self) -> None:
payload = self.console.save_member({"user_id": 2, "status": "active", "duration": "3_months"})
self.assertEqual(["/api/hub-admin/membership/save", "/api/hub-admin/members"], self.bridge.paths())
self.assertEqual([{"id": 2}], payload["users"])
def test_quota_is_a_system_setting_not_a_membership_row(self) -> None:
self.console.save_quota({"member_daily_limit": 80})
self.assertEqual("/api/hub-admin/settings/save", self.bridge.paths()[0])
self.assertEqual({"member_daily_limit": 80}, self.bridge.calls[0][1])
def test_quota_below_one_is_rejected_before_it_reaches_the_site(self) -> None:
for bad in (0, -5, "abc"):
with self.subTest(bad=bad):
with self.assertRaises(ApiError):
self.console.save_quota({"member_daily_limit": bad})
self.assertEqual([], self.bridge.paths())
class MarketTaskTests(unittest.TestCase):
"""HEL-566: 主站行情管理并入中枢后的桥接映射。"""
def setUp(self) -> None:
self.bridge = RecordingBridge(
{
"/api/hub-admin/status": {
"data": {
"background_refresh_enabled": True,
"configured": True,
"snapshot_dates": 12,
"jobs": [{"idempotency_key": "manual:20260916:1", "status": "success"}],
}
},
"/api/hub-admin/settings/save": {},
"/api/hub-admin/market/refresh": {"started": True, "job_key": "manual:20260916:1", "message": "后台刷新已开始"},
"/api/hub-admin/market/backfill": {"started": True, "job_key": "backfill:20260901:20260908:1", "message": "历史回补任务已开始"},
}
)
self.console = SiteConsole(self.bridge)
def test_market_status_projects_only_what_the_card_needs(self) -> None:
payload = self.console.market_status()
self.assertTrue(payload["background_refresh_enabled"])
self.assertEqual(1, len(payload["jobs"]))
self.assertNotIn("llm", payload)
self.assertNotIn("users", payload)
def test_toggling_background_refresh_saves_then_reads_back(self) -> None:
payload = self.console.save_market_settings({"background_refresh_enabled": False})
self.assertEqual(
["/api/hub-admin/settings/save", "/api/hub-admin/status"],
self.bridge.paths(),
)
self.assertEqual({"background_refresh_enabled": False}, self.bridge.calls[0][1])
self.assertTrue(payload["background_refresh_enabled"]) # 读回的是主站真实状态
def test_saving_without_the_toggle_is_refused(self) -> None:
with self.assertRaises(ApiError):
self.console.save_market_settings({})
self.assertEqual([], self.bridge.paths())
def test_refresh_and_backfill_forward_their_payloads(self) -> None:
refresh = self.console.market_refresh({"trade_date": "2026-09-16"})
self.assertTrue(refresh["started"])
self.assertEqual({"trade_date": "2026-09-16"}, self.bridge.calls[0][1])
backfill = self.console.market_backfill({"start_date": "2026-09-01", "end_date": "2026-09-08"})
self.assertEqual("backfill:20260901:20260908:1", backfill["job_key"])
self.assertEqual(
{"start_date": "2026-09-01", "end_date": "2026-09-08"},
self.bridge.calls[1][1],
)
class InviteTests(unittest.TestCase):
def setUp(self) -> None:
self.bridge = RecordingBridge(
{
"/api/hub-admin/invites": {"summary": {"unused": 1}, "codes": [{"code_id": "abc", "code_masked": "XB-9Q2F-••••"}]},
"/api/hub-admin/invites/create": {
"created": [{"code_id": "abc", "code": "XB-9Q2F-7K3M-2P8T"}],
"summary": {"unused": 1},
"codes": [{"code_id": "abc", "code_masked": "XB-9Q2F-••••"}],
},
}
)
self.console = SiteConsole(self.bridge)
def test_plaintext_codes_come_back_only_from_the_create_call(self) -> None:
created = self.console.create_invites({"count": 1}, created_by=1)
self.assertEqual("XB-9Q2F-7K3M-2P8T", created["created"][0]["code"])
# 列表里永远只有掩码,完整码不会再出现第二次
listed = self.console.invites()
self.assertEqual("XB-9Q2F-••••", listed["codes"][0]["code_masked"])
self.assertNotIn("code", listed["codes"][0])
def test_the_operator_is_recorded_as_the_issuer(self) -> None:
self.console.create_invites({"count": 3, "note": "给张总"}, created_by=7)
payload = self.bridge.calls[0][1]
self.assertEqual(7, payload["created_by"])
self.assertEqual(3, payload["count"])
self.assertEqual("给张总", payload["note"])
def test_revoking_uses_the_public_handle_never_the_raw_code(self) -> None:
self.console.revoke_invite({"code_id": "abc"})
self.assertEqual("/api/hub-admin/invites/revoke", self.bridge.paths()[0])
self.assertEqual({"code_id": "abc"}, self.bridge.calls[0][1])
def test_revoking_without_a_target_is_refused(self) -> None:
with self.assertRaises(ApiError):
self.console.revoke_invite({})
self.assertEqual([], self.bridge.paths())
if __name__ == "__main__":
unittest.main()
class BridgeContractTests(unittest.TestCase):
"""Both halves of the bridge must agree on the path spelling.
A typo here fails only at runtime with a confusing 401 (the review site
falls through to its browser-session guard), so it is worth a static check.
"""
def test_every_path_the_console_calls_is_registered_on_the_review_site(self) -> None:
console_paths = set()
for module in ("siteauth.py", "siteconsole.py"):
source = (ROOT / "datahub" / module).read_text(encoding="utf-8")
console_paths.update(re.findall(r'"(/api/hub-admin/[a-z/-]+)"', source))
self.assertTrue(console_paths)
registry = (REVIEW_ROOT / "backend" / "http" / "dispatch.py").read_text(encoding="utf-8")
registered = set(re.findall(r'"(/api/hub-admin/[a-z/-]+)":', registry))
self.assertEqual(
set(),
console_paths - registered,
"控制台在调、主站没注册的桥接路径会静默变成 401",
)
class BridgeErrorMappingTests(unittest.TestCase):
"""主站拒绝入参(Key 不对)不能在中枢这边冒成 500。"""
def _console(self, error: SiteBridgeError) -> SiteConsole:
class Failing:
def call(self, path, payload=None):
raise error
return SiteConsole(Failing())
def test_upstream_rejection_comes_back_as_a_bad_request(self) -> None:
console = self._console(SiteBridgeError("模型列表拉取失败(HTTP 401", 400))
with self.assertRaises(ApiError) as caught:
console.fetch_models({"base_url": "https://api.openai.com/v1", "api_key": "sk-bad"})
self.assertEqual(caught.exception.code, "INVALID_ARGUMENT")
self.assertEqual(caught.exception.status, HTTPStatus.BAD_REQUEST)
def test_unreachable_site_comes_back_as_service_unavailable(self) -> None:
console = self._console(SiteBridgeError("主站不可达:connection refused"))
with self.assertRaises(ApiError) as caught:
console.models()
self.assertEqual(caught.exception.code, "SOURCE_UNAVAILABLE")
self.assertEqual(caught.exception.status, HTTPStatus.SERVICE_UNAVAILABLE)