评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补 list_domains 与 get_account 余额 - NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到 到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由 - base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商 把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、 腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本 连同 MDN 链接弹给用户 动作层与 DNS - 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计 - 新增 Cloudflare DNS 记录的增删改查 定时同步 - scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30) - 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日 - sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期 会显示「成功 1」,用户以为数据拉到了、实际一条没有 数据一致性 - AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染, 缺了则所有按钮永远出不来 - import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步 把所有云端资产当新增再建一份 - _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未 实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量 补标签不覆盖,用户自建平台不动 前端如实展示 - cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回 价格,而汇总处又把 0 当「无费用」排除,两处口径不一致 - 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来 - DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底, 否则 Cloudflare 代理勾选框永远不出现 测试:257 -> 309 passed
197 lines
7.5 KiB
Python
197 lines
7.5 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-11),
|
||
#: 用它做 fixture 才能保证迁移验证的是生产形态而不是理想形态
|
||
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", "dynadot-api", "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", "cloudcone-api", "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", None, "vps"),
|
||
("vultr", "vultr-api", "vps"),
|
||
("zeabur", "zeabur-api", "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):
|
||
"""cloudcone/zeabur/dynadot 声明的适配器从未实现,必须清空"""
|
||
_run_migration(live_db)
|
||
snap = _snapshot(live_db)
|
||
for slug in ("cloudcone", "zeabur", "dynadot"):
|
||
assert snap[slug][0] is None, 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):
|
||
"""tothost 不在种子里,一个字都不能动"""
|
||
before = _snapshot(live_db)["tothost"]
|
||
_run_migration(live_db)
|
||
assert _snapshot(live_db)["tothost"] == 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)}"
|