"""VPS 到期日同步链路 + 批量资产同步测试 覆盖的核心回归:NormalizedVPS 此前没有 expiry_date 字段,阿里云/腾讯云把 ExpiredTime 抓进 raw 后直接丢弃,_sync_vps 也从不写 expiry_date(而 _sync_domains 一直有写)。结果是「云端同步进来的 VPS 永远进不了续费提醒」—— 而续费提醒正是这个台账存在的理由。 运行:.venv/bin/pytest tests/test_vps_expiry_sync.py -v """ from datetime import date from unittest.mock import patch 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.aliyun import AliyunAdapter from app.adapters.base import NormalizedVPS from app.adapters.tencent import TencentAdapter from app.models.asset import Account, Asset, AssetType, VPSDetail from app.models.provider import Provider from app.services import sync_service @pytest.fixture() def engine(tmp_path, monkeypatch): eng = create_engine( f"sqlite:///{tmp_path / 'sync.db'}", connect_args={"check_same_thread": False} ) SQLModel.metadata.create_all(eng) # sync_all_assets 的 worker 会自己开 Session(assets_engine),必须一并指到临时库 monkeypatch.setattr("app.database.assets_engine", eng) yield eng eng.dispose() class _FakeVPSAdapter: """最小适配器:只提供 _sync_vps 用到的接口""" required_config: list = [] def __init__(self, vps_list, domains_list=None): self.config = {} self._vps = vps_list self._domains = domains_list or [] def capabilities(self): return { "list_vps": bool(self._vps), "list_domains": bool(self._domains), "get_account": False, } def list_vps(self): return self._vps def list_domains(self): return self._domains def _seed(engine, slug="aliyun", sdk_type="aliyun-sdk", api_config=None): with Session(engine) as s: provider = Provider(slug=slug, name=slug, category="vps", sdk_type=sdk_type) s.add(provider) s.commit() s.refresh(provider) account = Account(name="acc", platform=slug, api_config_encrypted=api_config) s.add(account) s.commit() s.refresh(account) return provider.id, account.id # ----------------------------- 到期日链路 ----------------------------- # def test_normalized_vps_has_expiry_field(): """契约层面先钉住:字段必须存在且默认 None(后付费平台没有到期日)""" v = NormalizedVPS(external_id="i-1", name="n") assert v.expiry_date is None assert v.charge_type is None def test_normalized_vps_to_dict_serializes_expiry(): """to_dict 对外必须是 ISO 字符串,date 对象会让 JSON 序列化炸掉""" v = NormalizedVPS(external_id="i-1", name="n", expiry_date=date(2027, 3, 15)) assert v.to_dict()["expiry_date"] == "2027-03-15" assert NormalizedVPS(external_id="i-2", name="n").to_dict()["expiry_date"] is None def test_sync_vps_creates_with_expiry(engine): """新建分支必须写 expiry_date,否则云端同步的 VPS 进不了续费提醒""" provider_id, account_id = _seed(engine) adapter = _FakeVPSAdapter([ NormalizedVPS( external_id="i-prepaid", name="包年包月机", ip_address="1.2.3.4", status="active", expiry_date=date(2027, 3, 15), charge_type="PrePaid", ) ]) with Session(engine) as s: result = sync_service._sync_vps(s, s.get(Provider, provider_id), adapter, account_id) assert result == {"created": 1, "updated": 0} with Session(engine) as s: asset = s.exec(select(Asset)).first() assert asset.expiry_date == date(2027, 3, 15) assert asset.asset_type == AssetType.VPS assert asset.account_id == account_id detail = s.exec(select(VPSDetail)).first() assert detail.ip_address == "1.2.3.4" def test_sync_vps_coerces_string_expiry(engine): """适配器若仍返回 ISO 字符串,写库前归一为 date(否则 SQLite 抛 TypeError)""" provider_id, account_id = _seed(engine) adapter = _FakeVPSAdapter([ NormalizedVPS(external_id="i-1", name="n", expiry_date="2027-03-15") ]) with Session(engine) as s: sync_service._sync_vps(s, s.get(Provider, provider_id), adapter, account_id) with Session(engine) as s: assert s.exec(select(Asset)).first().expiry_date == date(2027, 3, 15) def test_sync_vps_postpaid_leaves_expiry_null(engine): """后付费(AWS 按需 / Linode / DO / Vultr)没有到期日,必须是 None 而不是假日期""" provider_id, account_id = _seed(engine) adapter = _FakeVPSAdapter([ NormalizedVPS(external_id="i-1", name="n", charge_type="postpaid", monthly_cost=12.0) ]) with Session(engine) as s: sync_service._sync_vps(s, s.get(Provider, provider_id), adapter, account_id) with Session(engine) as s: asset = s.exec(select(Asset)).first() assert asset.expiry_date is None assert asset.cost == 12.0 def test_sync_vps_updates_existing_expiry(engine): """重复同步走更新分支:到期日按新值刷新,不新建资产""" provider_id, account_id = _seed(engine) def _run(expiry): adapter = _FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", expiry_date=expiry)]) with Session(engine) as s: return sync_service._sync_vps(s, s.get(Provider, provider_id), adapter, account_id) assert _run(date(2027, 3, 15)) == {"created": 1, "updated": 0} assert _run(date(2028, 3, 15)) == {"created": 0, "updated": 1} with Session(engine) as s: assets = s.exec(select(Asset)).all() assert len(assets) == 1 assert assets[0].expiry_date == date(2028, 3, 15) def test_sync_vps_never_clears_expiry(engine): """云端返回 None 时不能把已有的到期日抹掉 阿里云 DescribeInstances 偶发不返回 ExpiredTime,若照搬 None 就会让 一台明明要到期的机器从续费提醒里消失。 """ provider_id, account_id = _seed(engine) with Session(engine) as s: sync_service._sync_vps( s, s.get(Provider, provider_id), _FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", expiry_date=date(2027, 3, 15))]), account_id, ) with Session(engine) as s: sync_service._sync_vps( s, s.get(Provider, provider_id), _FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", expiry_date=None)]), account_id, ) with Session(engine) as s: assert s.exec(select(Asset)).first().expiry_date == date(2027, 3, 15) def test_sync_vps_updates_cost_only_when_provided(engine): """monthly_cost 为 None 时保留原值,别把台账里手填的成本冲成 0""" provider_id, account_id = _seed(engine) with Session(engine) as s: sync_service._sync_vps( s, s.get(Provider, provider_id), _FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", monthly_cost=20.0)]), account_id, ) with Session(engine) as s: sync_service._sync_vps( s, s.get(Provider, provider_id), _FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", monthly_cost=None)]), account_id, ) with Session(engine) as s: assert s.exec(select(Asset)).first().cost == 20.0 # ----------------------------- 真实适配器的到期日映射 ----------------------------- # def test_aliyun_maps_expired_time_to_expiry(): """阿里云包年包月实例的 ExpiredTime 此前被抓进 raw 后直接丢弃""" adapter = AliyunAdapter({"access_key_id": "id", "access_key_secret": "sec", "region_id": "cn-hangzhou"}) payload = { "Instances": { "Instance": [ { "InstanceId": "i-ali", "InstanceName": "ali-web", "Status": "Running", "PublicIpAddress": {"IpAddress": ["1.2.3.4"]}, "ExpiredTime": "2027-03-15T16:00Z", "InstanceChargeType": "PrePaid", } ] } } with patch.object(AliyunAdapter, "_call", return_value=payload): vps = adapter.list_vps() assert len(vps) == 1 assert vps[0].expiry_date == date(2027, 3, 15) assert vps[0].charge_type == "prepaid" # 适配器统一归一为小写,前端好判断 def test_aliyun_postpaid_has_no_expiry(): adapter = AliyunAdapter({"access_key_id": "id", "access_key_secret": "sec"}) payload = { "Instances": { "Instance": [ {"InstanceId": "i-post", "InstanceName": "n", "Status": "Running", "InstanceChargeType": "PostPaid"} ] } } with patch.object(AliyunAdapter, "_call", return_value=payload): vps = adapter.list_vps() assert vps[0].expiry_date is None assert vps[0].charge_type == "postpaid" def test_tencent_maps_expired_time_to_expiry(): """腾讯云 CVM 的 ExpiredTime 同样此前被丢弃 注意 _call 返回的是已剥掉 Response 信封的内层 dict(TC3 的统一约定)。 """ adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY", "region": "ap-guangzhou"}) payload = { "InstanceSet": [ { "InstanceId": "ins-tx", "InstanceName": "tx-web", "InstanceState": "RUNNING", "PublicIpAddresses": ["5.6.7.8"], "ExpiredTime": "2027-03-15T16:00:00Z", "InstanceChargeType": "PREPAID", } ], "TotalCount": 1, } with patch.object(TencentAdapter, "_call", return_value=payload): vps = adapter.list_vps() assert len(vps) == 1 assert vps[0].expiry_date == date(2027, 3, 15) assert vps[0].charge_type == "prepaid" def test_tencent_postpaid_by_hour_has_no_expiry(): """腾讯云按量计费(POSTPAID_BY_HOUR)没有到期日""" adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"}) payload = { "InstanceSet": [ {"InstanceId": "ins-h", "InstanceName": "n", "InstanceState": "RUNNING", "InstanceChargeType": "POSTPAID_BY_HOUR"} ], "TotalCount": 1, } with patch.object(TencentAdapter, "_call", return_value=payload): vps = adapter.list_vps() assert vps[0].expiry_date is None assert vps[0].charge_type == "postpaid" # ----------------------------- 批量同步 ----------------------------- # def test_sync_all_assets_skips_accounts_without_config(engine, monkeypatch): """未配凭证的账号计入 skipped 而非 failed 线上 12 个账号全部 has_api=False,若算 failed,每次定时同步都会报一堆错。 """ _seed(engine, slug="aliyun", api_config=None) with Session(engine) as s: result = sync_service.sync_all_assets(s) assert result["total"] == 0 assert result["skipped_no_config"] == 1 assert result["failed"] == 0 def test_sync_all_assets_skips_unsupported_sdk_type(engine, monkeypatch): """平台没有可用适配器时计入 skipped_no_adapter,不该报成错误""" _seed(engine, slug="cloudcone", sdk_type=None, api_config="enc") with Session(engine) as s: result = sync_service.sync_all_assets(s) assert result["total"] == 0 assert result["skipped_no_adapter"] == 1 assert result["failed"] == 0 def test_sync_all_assets_skips_unknown_platform(engine, monkeypatch): """账号挂在一个不存在的平台上,不能被当成同步失败""" with Session(engine) as s: s.add(Account(name="orphan", platform="no-such-platform", api_config_encrypted="enc")) s.commit() with Session(engine) as s: result = sync_service.sync_all_assets(s) assert result["total"] == 0 assert result["skipped_no_adapter"] == 1 def test_sync_all_assets_aggregates_counts(engine, monkeypatch): """成功路径:created/updated 要把 vps 与 domains 两类都累加进来""" _seed(engine, slug="aliyun", sdk_type="aliyun-sdk", api_config="enc") fake = _FakeVPSAdapter( [NormalizedVPS(external_id="i-1", name="n", expiry_date=date(2027, 1, 1))], domains_list=[], ) monkeypatch.setattr( sync_service, "sync_account", lambda session, account_id: {"vps": {"created": 2, "updated": 1}, "domains": {"created": 3, "updated": 0}}, ) with Session(engine) as s: result = sync_service.sync_all_assets(s) assert result["total"] == 1 assert result["success"] == 1 assert result["failed"] == 0 assert result["created"] == 5 assert result["updated"] == 1 assert fake.capabilities()["list_vps"] is True def test_sync_all_assets_surfaces_partial_failure(engine, monkeypatch): """某一类资产同步失败不算账号整体失败,但必须在 errors 里如实上报""" _seed(engine, slug="aliyun", sdk_type="aliyun-sdk", api_config="enc") monkeypatch.setattr( sync_service, "sync_account", lambda session, account_id: { "vps": None, "vps_error": "签名失败", "domains": {"created": 1, "updated": 0}, }, ) with Session(engine) as s: result = sync_service.sync_all_assets(s) assert result["success"] == 1 assert result["failed"] == 0 assert any("vps_error" in e for e in result["errors"]) def test_sync_all_assets_all_subtasks_failed_counts_as_failed(engine, monkeypatch): """每一项尝试都失败时必须算 failed 凭证过期的典型现场:list_vps / list_domains / get_account 全部 401。 这时若仍计「成功」,前端汇总行会显示「成功 1,失败 0」, 用户以为数据拉到了、实际一条都没有——比报错更危险。 """ _seed(engine, slug="vultr", sdk_type="vultr-api", api_config="enc") monkeypatch.setattr( sync_service, "sync_account", lambda session, account_id: { "vps": None, "vps_error": "HTTP 401:Invalid API token.", "domains": None, "domains_error": "HTTP 401:Invalid API token.", "account_info": None, "account_info_error": "HTTP 401:Invalid API token.", }, ) with Session(engine) as s: result = sync_service.sync_all_assets(s) assert result["success"] == 0 assert result["failed"] == 1 assert result["created"] == 0 and result["updated"] == 0 assert len(result["errors"]) == 3 # 三类错误都要如实列出 def test_sync_all_assets_counts_hard_failure(engine, monkeypatch): """sync_account 直接抛错时算 failed,且带上账号标签便于定位""" _seed(engine, slug="aliyun", sdk_type="aliyun-sdk", api_config="enc") def _boom(session, account_id): raise RuntimeError("凭证解密失败") monkeypatch.setattr(sync_service, "sync_account", _boom) with Session(engine) as s: result = sync_service.sync_all_assets(s) assert result["failed"] == 1 assert result["success"] == 0 assert any("凭证解密失败" in e for e in result["errors"]) def test_sync_all_assets_result_shape(engine, monkeypatch): """脚本 sync_all_assets.py 依赖这些键存在,改名会静默打出 None""" _seed(engine, slug="cloudcone", sdk_type=None, api_config=None) with Session(engine) as s: result = sync_service.sync_all_assets(s) for key in ("total", "success", "failed", "created", "updated", "skipped_no_config", "skipped_no_adapter", "errors", "synced_at"): assert key in result, f"返回结构缺少 {key}"