Files
vps-manager/tests/test_sync_service.py
T

337 lines
11 KiB
Python

"""sync_service 核心同步流程测试
覆盖 sync_account 的三个关键场景:
1. 正常同步入库:适配器返回 VPS + 域名,验证资产正确写入数据库
2. (provider_id, external_id) 去重:同 provider_id + external_id 的资产再次同步时更新而非新建
3. 适配器异常容错:VPS 同步抛异常时不中断整体流程,域名仍可同步,错误记录在结果中
全程 tmp_path + create_engine 临时库,mock 适配器,免凭证免网络。
运行:.venv/bin/python -m pytest tests/test_sync_service.py -v
"""
from datetime import date, timedelta
import pytest
from cryptography.fernet import Fernet
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.base import (
AccountInfo,
BaseAdapter,
NormalizedDomain,
NormalizedVPS,
)
from app.core import crypto
from app.core.config import settings
from app.models.asset import Account, Asset, AssetStatus, AssetType, DomainDetail, VPSDetail
from app.models.provider import Provider
from app.services import sync_service
# ----------------------------- fixtures ----------------------------- #
@pytest.fixture()
def engine(tmp_path, monkeypatch):
"""临时资产库"""
eng = create_engine(
f"sqlite:///{tmp_path / 'assets.db'}", connect_args={"check_same_thread": False}
)
SQLModel.metadata.create_all(eng)
monkeypatch.setattr("app.database.metrics_engine", eng)
yield eng
eng.dispose()
@pytest.fixture()
def master_key(monkeypatch):
"""临时 Fernet key,不依赖 .env 里的真实 MASTER_KEY"""
monkeypatch.setattr(settings, "MASTER_KEY", Fernet.generate_key().decode())
crypto._get_fernet.cache_clear()
yield
crypto._get_fernet.cache_clear()
# ----------------------------- mock adapter ----------------------------- #
class MockAdapter(BaseAdapter):
"""可配置返回值的测试适配器"""
required_config = ["api_key"]
def __init__(self, config=None, *, vps_list=None, domain_list=None, account_info=None,
vps_raises=None, domain_raises=None):
super().__init__(config)
self._vps_list = vps_list or []
self._domain_list = domain_list or []
self._account_info = account_info
self._vps_raises = vps_raises
self._domain_raises = domain_raises
def test_connection(self):
return {"ok": True, "message": "mock"}
def list_vps(self):
if self._vps_raises:
raise self._vps_raises
return self._vps_list
def list_domains(self):
if self._domain_raises:
raise self._domain_raises
return self._domain_list
def get_account(self):
if self._account_info:
return self._account_info
return AccountInfo(balance=100.0, currency="USD")
def capabilities(self):
return {"list_vps": True, "list_domains": True, "get_account": True}
def _seed_provider(session, slug="vultr", name="Vultr", sdk_type="vultr-api"):
"""在库中种入一个 Provider 并返回"""
p = Provider(slug=slug, name=name, sdk_type=sdk_type, category="vps")
session.add(p)
session.commit()
session.refresh(p)
return p
def _seed_account(session, platform="vultr", name="test@example.com", api_config=None):
"""在库中种入一个 Account 并返回"""
from app.core.crypto import encrypt
config_enc = encrypt(api_config or '{"api_key": "fake-key"}')
acc = Account(name=name, platform=platform, api_config_encrypted=config_enc)
session.add(acc)
session.commit()
session.refresh(acc)
return acc
def _patch_adapter(monkeypatch, adapter):
"""让 _build_adapter_for_account 返回固定的 mock 适配器"""
def _fake_build(session, account):
provider = session.exec(select(Provider).where(Provider.slug == account.platform)).first()
return provider, adapter
monkeypatch.setattr(sync_service, "_build_adapter_for_account", _fake_build)
# ----------------------------- 场景 1:正常同步入库 ----------------------------- #
def test_sync_account_normal路径_vps_and_domains_created(engine, master_key, monkeypatch):
"""适配器返回 2 台 VPS + 1 个域名,同步后全部正确入库"""
today = date.today()
vps_list = [
NormalizedVPS(
external_id="vps-001", name="web-server",
ip_address="1.2.3.4", region="Tokyo", os="Debian 12",
cpu_cores=2, memory_gb=4, disk_gb=80,
status="active", monthly_cost=12.5, currency="USD",
expiry_date=today + timedelta(days=30),
),
NormalizedVPS(
external_id="vps-002", name="db-server",
ip_address="5.6.7.8", region="Singapore",
status="active",
),
]
domain_list = [
NormalizedDomain(
external_id="dom-001", domain_name="example.com",
registrar="namesilo", expiry_date=today + timedelta(days=365),
status="active",
),
]
adapter = MockAdapter(config={"api_key": "fake"}, vps_list=vps_list, domain_list=domain_list)
_patch_adapter(monkeypatch, adapter)
with Session(engine) as s:
provider = _seed_provider(s)
account = _seed_account(s)
result = sync_service.sync_account(s, account.id)
# 验证返回结构
assert result["provider"] == "vultr"
assert result["vps"]["created"] == 2
assert result["vps"]["updated"] == 0
assert result["domains"]["created"] == 1
assert result["domains"]["updated"] == 0
assert "vps_error" not in result
assert "domains_error" not in result
assert result["last_synced_at"] is not None
# 验证数据库中的资产
all_assets = s.exec(select(Asset)).all()
assert len(all_assets) == 3 # 2 VPS + 1 domain
vps_assets = [a for a in all_assets if a.asset_type == AssetType.VPS]
dom_assets = [a for a in all_assets if a.asset_type == AssetType.DOMAIN]
assert len(vps_assets) == 2
assert len(dom_assets) == 1
# 验证 VPS 详情
web = next(a for a in vps_assets if a.external_id == "vps-001")
assert web.name == "web-server"
assert web.status == AssetStatus.ACTIVE
assert web.cost == 12.5
assert web.account_id == account.id
detail = s.exec(select(VPSDetail).where(VPSDetail.asset_id == web.id)).first()
assert detail is not None
assert detail.ip_address == "1.2.3.4"
assert detail.region == "Tokyo"
assert detail.cpu_cores == 2
# 验证域名详情
dom = dom_assets[0]
assert dom.external_id == "dom-001"
dom_detail = s.exec(select(DomainDetail).where(DomainDetail.asset_id == dom.id)).first()
assert dom_detail.domain_name == "example.com"
assert dom_detail.registrar == "namesilo"
# ----------------------------- 场景 2:去重逻辑 ----------------------------- #
def test_sync_account_去重_provider_id_external_id(engine, master_key, monkeypatch):
"""相同 (provider_id, external_id, asset_type) 的资产再次同步时更新而非新建"""
today = date.today()
# 第一次同步:创建 1 台 VPS
first_vps = [
NormalizedVPS(
external_id="vps-001", name="web-server",
ip_address="1.2.3.4", status="active",
expiry_date=today + timedelta(days=30),
),
]
adapter1 = MockAdapter(config={"api_key": "fake"}, vps_list=first_vps)
_patch_adapter(monkeypatch, adapter1)
with Session(engine) as s:
provider = _seed_provider(s)
account = _seed_account(s)
result1 = sync_service.sync_account(s, account.id)
assert result1["vps"]["created"] == 1
assert result1["vps"]["updated"] == 0
# 第二次同步:同一 external_id,名称和 IP 变了
second_vps = [
NormalizedVPS(
external_id="vps-001", name="web-server-renamed",
ip_address="9.9.9.9", status="active",
expiry_date=today + timedelta(days=60),
),
]
adapter2 = MockAdapter(config={"api_key": "fake"}, vps_list=second_vps)
_patch_adapter(monkeypatch, adapter2)
with Session(engine) as s:
result2 = sync_service.sync_account(s, account.id)
assert result2["vps"]["created"] == 0
assert result2["vps"]["updated"] == 1
# 验证没有新建资产,而是更新了已有资产
all_assets = s.exec(select(Asset)).all()
assert len(all_assets) == 1 # 仍然只有 1 条
asset = all_assets[0]
assert asset.name == "web-server-renamed"
assert asset.expiry_date == today + timedelta(days=60)
# 验证 VPS 详情也更新了
detail = s.exec(select(VPSDetail).where(VPSDetail.asset_id == asset.id)).first()
assert detail.ip_address == "9.9.9.9"
# ----------------------------- 场景 3:适配器异常容错 ----------------------------- #
def test_sync_account_vps异常_域名仍可同步(engine, master_key, monkeypatch):
"""VPS 同步抛异常时,域名同步不受影响,错误记录在结果中"""
today = date.today()
domain_list = [
NormalizedDomain(
external_id="dom-001", domain_name="example.com",
registrar="namesilo", expiry_date=today + timedelta(days=365),
status="active",
),
]
# VPS 同步会抛异常,但域名正常
adapter = MockAdapter(
config={"api_key": "fake"},
vps_raises=RuntimeError("API rate limit exceeded"),
domain_list=domain_list,
)
_patch_adapter(monkeypatch, adapter)
with Session(engine) as s:
provider = _seed_provider(s)
account = _seed_account(s)
result = sync_service.sync_account(s, account.id)
# VPS 失败应记录错误
assert "vps_error" in result
assert "rate limit" in result["vps_error"].lower()
assert "vps" not in result or result.get("vps") is None
# 域名应正常同步
assert result["domains"]["created"] == 1
assert result["domains"]["updated"] == 0
assert "domains_error" not in result
# 数据库中只有域名,没有 VPS
all_assets = s.exec(select(Asset)).all()
assert len(all_assets) == 1
assert all_assets[0].asset_type == AssetType.DOMAIN
def test_sync_account_domain异常_vps仍可同步(engine, master_key, monkeypatch):
"""域名同步抛异常时,VPS 同步不受影响(对称验证)"""
vps_list = [
NormalizedVPS(
external_id="vps-001", name="web-server",
ip_address="1.2.3.4", status="active",
),
]
adapter = MockAdapter(
config={"api_key": "fake"},
vps_list=vps_list,
domain_raises=ConnectionError("DNS resolution failed"),
)
_patch_adapter(monkeypatch, adapter)
with Session(engine) as s:
provider = _seed_provider(s)
account = _seed_account(s)
result = sync_service.sync_account(s, account.id)
# VPS 正常
assert result["vps"]["created"] == 1
assert "vps_error" not in result
# 域名失败
assert "domains_error" in result
assert "DNS resolution" in result["domains_error"]
all_assets = s.exec(select(Asset)).all()
assert len(all_assets) == 1
assert all_assets[0].asset_type == AssetType.VPS