线上平台列表里 cloudcone、zeabur、xinwang、dynadot、agnes 的 sdk_type 仍为空, 平台卡片只能显示「未对接」,用户无法同步。按各自真实 API 能力补齐: - CloudCone:实例清单 + 开机/关机/重启;无公开账单接口,不声明余额与续费 - Zeabur:服务器清单 + 重启;毫核/MB 归一化为核/GB,未采集时不伪造规格 - Dynadot:域名清单(含到期日)+ 账户余额;set_dns2 位置参数无法安全 CRUD - 新网:代理 API 按域名逐个查到期日;无列表接口,需在账号里填 domains 清单 - Agnes:/models 校验 Key;无余额接口,如实标记不支持 配套改动: - _align_preset_providers 为库中为空的 sdk_type 回填种子值(启动即生效), 避免新适配器上线后线上仍停留在「未对接」 - 适配器可声明 plain_config,代理号/域名等非密钥字段前端按明文输入 - PRESET_PROVIDERS 五条记录填上对应 sdk_type 测试:445 passed(新增 tests/test_platform_adapters.py),vitest 62 passed。
215 lines
8.3 KiB
Python
215 lines
8.3 KiB
Python
"""平台注册表对齐测试
|
||
|
||
覆盖两处回归:
|
||
|
||
1. `_align_preset_providers`:seed_providers 只插入缺失 slug、不更新已有行,
|
||
所以代码侧的能力变化必须靠迁移落地。迁移必须是「增量」的——线上 aliyun-intl
|
||
的 services 被用户手工加过 ai_agent/other,按种子值整体覆盖会静默丢用户配置。
|
||
2. 种子与注册表的一致性:任何预设平台都不得声明未实现的 sdk_type。
|
||
声明了前端就会显示「API 对接: xxx」,用户建好账号点同步才撞
|
||
「暂不支持的 SDK 类型」——声明支持却不支持,比明说不支持更糟。
|
||
|
||
运行:.venv/bin/pytest tests/test_provider_alignment.py -v
|
||
"""
|
||
|
||
import sqlalchemy as sa
|
||
import pytest
|
||
from sqlmodel import Session, SQLModel, create_engine, select
|
||
|
||
import app.models.asset # noqa: F401
|
||
import app.models.credential # noqa: F401
|
||
import app.models.monitor # noqa: F401
|
||
import app.models.provider # noqa: F401
|
||
import app.models.ssl # noqa: F401
|
||
from app.adapters import registry
|
||
from app.core.seed import PRESET_PROVIDERS, seed_providers
|
||
from app.database import _align_preset_providers
|
||
from app.models.provider import Provider
|
||
|
||
#: 线上 GET /api/providers 抓下来的真实状态(2026-10-26),
|
||
#: 用它做 fixture 才能保证迁移验证的是生产形态而不是理想形态。
|
||
#: cloudcone/zeabur/dynadot/xinwang/agnes 上线前都是 None,靠启动迁移补 sdk_type。
|
||
LIVE_PROVIDERS = [
|
||
("agnes", None, "ai_agent"),
|
||
("deepseek", "deepseek-api", "ai_agent"),
|
||
("kimi", "moonshot-api", "ai_agent"),
|
||
("minimax", "minimax-api", "ai_agent"),
|
||
("openai", "openai-api", "ai_agent"),
|
||
("cloudflare", "cloudflare-api", "cloudflare,dns,ssl_cert,cdn"),
|
||
("dynadot", None, "domain,dns"),
|
||
("namesilo", "namesilo-api", "domain,dns"),
|
||
("xinwang", None, "domain,dns"),
|
||
("aliyun", "aliyun-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
||
("aliyun-intl", "alibabacloud-sdk", "vps,domain,ssl_cert,cdn,dns,ai_agent,other"),
|
||
("cloudcone", None, "vps"),
|
||
("digitalocean", "do-api", "vps,domain"),
|
||
("linode", "linode-api", "vps"),
|
||
("tencent", "tencent-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
||
("tencent-intl", "tencent-intl-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
||
("tothost", "whmcs-panel", "vps"),
|
||
("vultr", "vultr-api", "vps"),
|
||
("zeabur", None, "vps,domain"),
|
||
]
|
||
|
||
|
||
@pytest.fixture()
|
||
def engine(tmp_path):
|
||
eng = create_engine(
|
||
f"sqlite:///{tmp_path / 'providers.db'}", connect_args={"check_same_thread": False}
|
||
)
|
||
SQLModel.metadata.create_all(eng)
|
||
yield eng
|
||
eng.dispose()
|
||
|
||
|
||
@pytest.fixture()
|
||
def live_db(engine):
|
||
"""复刻线上 providers 表"""
|
||
with Session(engine) as s:
|
||
for slug, sdk, svc in LIVE_PROVIDERS:
|
||
s.add(Provider(slug=slug, name=slug, category="vps", services=svc, sdk_type=sdk))
|
||
s.commit()
|
||
return engine
|
||
|
||
|
||
def _snapshot(engine) -> dict:
|
||
with engine.begin() as conn:
|
||
rows = conn.execute(sa.text("SELECT slug, sdk_type, services FROM providers")).fetchall()
|
||
return {r[0]: (r[1], r[2]) for r in rows}
|
||
|
||
|
||
def _run_migration(engine) -> None:
|
||
with engine.begin() as conn:
|
||
_align_preset_providers(conn)
|
||
|
||
|
||
# ----------------------------- 种子 / 注册表一致性 ----------------------------- #
|
||
def test_no_preset_declares_unimplemented_sdk_type():
|
||
"""预设平台不得声明未实现的 sdk_type(AWS/Linode/Namesilo 落地后的不变量)"""
|
||
supported = set(registry.supported_types())
|
||
bogus = [(p["slug"], p["sdk_type"]) for p in PRESET_PROVIDERS
|
||
if p.get("sdk_type") and p["sdk_type"] not in supported]
|
||
assert bogus == [], f"这些平台声明了未实现的适配器:{bogus}"
|
||
|
||
|
||
def test_new_adapters_are_registered():
|
||
"""本轮新增的三家必须有对应注册项"""
|
||
for sdk_type in ("aws-api", "linode-api", "namesilo-api"):
|
||
assert registry.is_supported(sdk_type), f"{sdk_type} 未注册"
|
||
|
||
|
||
def test_seed_providers_inserts_missing_slug(live_db):
|
||
"""线上没有 aws 行,seed_providers 必须补上"""
|
||
assert "aws" not in _snapshot(live_db)
|
||
with Session(live_db) as s:
|
||
added = seed_providers(s)
|
||
snap = _snapshot(live_db)
|
||
assert added >= 1
|
||
assert snap["aws"][0] == "aws-api"
|
||
assert "vps" in snap["aws"][1]
|
||
|
||
|
||
def test_seed_providers_is_idempotent(live_db):
|
||
with Session(live_db) as s:
|
||
first = seed_providers(s)
|
||
second = seed_providers(s)
|
||
assert second == 0
|
||
assert len(_snapshot(live_db)) == len(LIVE_PROVIDERS) + first
|
||
|
||
|
||
# ----------------------------- 迁移行为 ----------------------------- #
|
||
def test_migration_clears_bogus_sdk_type(live_db):
|
||
"""库里声明了注册表没有的 sdk_type(旧版本残留)必须清空"""
|
||
with live_db.begin() as conn:
|
||
conn.execute(sa.text("UPDATE providers SET sdk_type = 'vultr-v1-removed' WHERE slug = 'vultr'"))
|
||
_run_migration(live_db)
|
||
assert _snapshot(live_db)["vultr"][0] is None
|
||
|
||
|
||
def test_migration_backfills_missing_sdk_type(live_db):
|
||
"""新增适配器上线后,库里 sdk_type 为空的预设平台必须补上,否则卡片永远不可同步"""
|
||
_run_migration(live_db)
|
||
snap = _snapshot(live_db)
|
||
for slug, expected in (
|
||
("cloudcone", "cloudcone-api"),
|
||
("zeabur", "zeabur-api"),
|
||
("dynadot", "dynadot-api"),
|
||
("xinwang", "xinnet-api"),
|
||
("agnes", "agnes-api"),
|
||
):
|
||
assert snap[slug][0] == expected, f"{slug}.sdk_type 未回填:{snap[slug][0]!r}"
|
||
|
||
|
||
def test_migration_keeps_valid_sdk_type(live_db):
|
||
"""有效 sdk_type 不能被误伤,否则同步能力全丢"""
|
||
_run_migration(live_db)
|
||
snap = _snapshot(live_db)
|
||
for slug, expected in (
|
||
("aliyun", "aliyun-sdk"), ("tencent", "tencent-sdk"), ("vultr", "vultr-api"),
|
||
("digitalocean", "do-api"), ("linode", "linode-api"), ("namesilo", "namesilo-api"),
|
||
("cloudflare", "cloudflare-api"), ("openai", "openai-api"),
|
||
):
|
||
assert snap[slug][0] == expected
|
||
|
||
|
||
def test_migration_preserves_user_customized_services(live_db):
|
||
"""aliyun-intl 被用户手工加过 ai_agent/other,覆盖式迁移会静默丢配置"""
|
||
_run_migration(live_db)
|
||
tags = set(_snapshot(live_db)["aliyun-intl"][1].split(","))
|
||
assert {"ai_agent", "other"} <= tags
|
||
|
||
|
||
def test_migration_adds_missing_service_tags(live_db):
|
||
"""适配器新增了域名/DNS 能力,services 必须跟上,
|
||
否则 modals.js 按 services 过滤时选不到这些平台"""
|
||
_run_migration(live_db)
|
||
snap = _snapshot(live_db)
|
||
assert {"domain", "dns"} <= set(snap["vultr"][1].split(","))
|
||
assert {"domain", "dns"} <= set(snap["linode"][1].split(","))
|
||
assert "dns" in snap["digitalocean"][1].split(",")
|
||
|
||
|
||
def test_migration_ignores_user_created_providers(live_db):
|
||
"""用户自建平台不在种子里,一个字都不能动"""
|
||
with Session(live_db) as s:
|
||
s.add(Provider(slug="myhost", name="MyHost", category="vps", services="vps"))
|
||
s.commit()
|
||
before = _snapshot(live_db)["myhost"]
|
||
_run_migration(live_db)
|
||
assert _snapshot(live_db)["myhost"] == before
|
||
|
||
|
||
def test_migration_is_idempotent(live_db):
|
||
"""迁移跑两次结果必须一致(update.sh 每分钟可能触发重启)"""
|
||
_run_migration(live_db)
|
||
once = _snapshot(live_db)
|
||
_run_migration(live_db)
|
||
_run_migration(live_db)
|
||
assert _snapshot(live_db) == once
|
||
|
||
|
||
def test_migration_leaves_no_unimplemented_declaration(live_db):
|
||
"""终态不变量:迁移 + seed 后,库里不存在「声明但未实现」的 sdk_type"""
|
||
_run_migration(live_db)
|
||
with Session(live_db) as s:
|
||
seed_providers(s)
|
||
_run_migration(live_db)
|
||
|
||
supported = set(registry.supported_types())
|
||
snap = _snapshot(live_db)
|
||
bad = [(slug, v[0]) for slug, v in snap.items() if v[0] and v[0] not in supported]
|
||
assert bad == []
|
||
|
||
|
||
def test_migration_result_covers_all_seed_services(live_db):
|
||
"""终态:每个预设平台的 services 至少包含种子声明的标签"""
|
||
_run_migration(live_db)
|
||
snap = _snapshot(live_db)
|
||
for data in PRESET_PROVIDERS:
|
||
slug = data["slug"]
|
||
if slug not in snap:
|
||
continue
|
||
seed_tags = {t.strip() for t in (data.get("services") or "").split(",") if t.strip()}
|
||
cur_tags = {t.strip() for t in (snap[slug][1] or "").split(",") if t.strip()}
|
||
assert seed_tags <= cur_tags, f"{slug} 缺标签 {sorted(seed_tags - cur_tags)}"
|