"""资产导出 / 导入的往返一致性测试 export_assets 自称「用于数据迁移/备份」,那它导出的字段就必须在 import_assets 里真的落库。external_id 是 sync_service 的去重键 (provider_id, external_id, asset_type), AssetCreate/AssetUpdate 不收这个字段——导入时若被 pydantic 静默丢弃, 迁移后首次同步会把所有云端资产当成新增再建一份,台账直接翻倍。 运行:.venv/bin/pytest tests/test_asset_export_import.py -v """ 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.models.asset import Asset, AssetType, DomainDetail, VPSDetail from app.services.asset_service import export_assets, import_assets @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) # _cleanup_metrics_for_asset 在函数内部取 metrics_engine,指到临时库避免污染本地 data/ monkeypatch.setattr("app.database.metrics_engine", eng) yield eng eng.dispose() def _seed_vps(engine, name="web-01", external_id="i-abc123"): with Session(engine) as s: a = Asset( name=name, asset_type=AssetType.VPS, provider="vultr", external_id=external_id, cost=5.0, currency="USD", ) s.add(a) s.commit() s.refresh(a) s.add(VPSDetail(asset_id=a.id, ip_address="1.2.3.4")) s.commit() return a.id def _seed_domain(engine, name="example.com", external_id="example.com"): with Session(engine) as s: a = Asset(name=name, asset_type=AssetType.DOMAIN, provider="namesilo", external_id=external_id) s.add(a) s.commit() s.refresh(a) s.add(DomainDetail(asset_id=a.id, domain_name=name)) s.commit() return a.id def _external_ids(engine): with Session(engine) as s: return {a.name: a.external_id for a in s.exec(select(Asset)).all()} def _wipe(engine): """清空资产与详情行 必须连详情一起删:SQLite 默认不强制外键,只删 Asset 会留下孤儿详情行; 而 assets.id 是 INTEGER PRIMARY KEY(无 AUTOINCREMENT),删掉后新建资产 必然复用同一个 id,撞上孤儿行的 UNIQUE(asset_id)。 """ with Session(engine) as s: for model in (VPSDetail, DomainDetail): for row in s.exec(select(model)).all(): s.delete(row) for a in s.exec(select(Asset)).all(): s.delete(a) s.commit() # ----------------------------- 导出 ----------------------------- # def test_export_includes_external_id(engine): _seed_vps(engine) with Session(engine) as s: data = export_assets(s) assert data["count"] == 1 assert data["assets"][0]["external_id"] == "i-abc123" def test_export_carries_detail_and_count(engine): _seed_vps(engine) _seed_domain(engine) with Session(engine) as s: data = export_assets(s) assert data["count"] == 2 by_name = {a["name"]: a for a in data["assets"]} assert by_name["web-01"]["vps_detail"]["ip_address"] == "1.2.3.4" assert by_name["example.com"]["domain_detail"]["domain_name"] == "example.com" # ----------------------------- 导入落库 ----------------------------- # def test_import_creates_asset_with_external_id(engine): """空库导入:external_id 必须落到行上,不能被 AssetCreate 静默吞掉""" _seed_vps(engine) with Session(engine) as s: payload = export_assets(s)["assets"] _wipe(engine) with Session(engine) as s: result = import_assets(s, payload) assert result["errors"] == [] assert result["created"] == 1 assert _external_ids(engine)["web-01"] == "i-abc123" def test_import_updates_existing_and_keeps_external_id(engine): """同名同类型同平台 -> 走更新分支,external_id 不能丢""" asset_id = _seed_vps(engine) with Session(engine) as s: payload = export_assets(s)["assets"] with Session(engine) as s: a = s.get(Asset, asset_id) a.external_id = None # 模拟历史数据没有 external_id s.add(a) s.commit() with Session(engine) as s: result = import_assets(s, payload) assert result["updated"] == 1 and result["created"] == 0 assert _external_ids(engine)["web-01"] == "i-abc123" def test_import_does_not_clobber_a_fresher_external_id(engine): """已有值时不覆盖:导入包可能比线上同步到的旧,覆盖会让去重键指向已销毁的实例""" asset_id = _seed_vps(engine, external_id="i-old") with Session(engine) as s: payload = export_assets(s)["assets"] with Session(engine) as s: a = s.get(Asset, asset_id) a.external_id = "i-new" s.add(a) s.commit() with Session(engine) as s: import_assets(s, payload) assert _external_ids(engine)["web-01"] == "i-new" def test_import_ignores_computed_fields(engine): """days_to_expiry / provider_name / account_name 是派生值,导入时必须跳过而不是写库""" _seed_vps(engine) with Session(engine) as s: payload = export_assets(s)["assets"] payload[0]["days_to_expiry"] = 999 payload[0]["provider_name"] = "不存在的平台" payload[0]["account_name"] = "不存在的账号" _wipe(engine) with Session(engine) as s: result = import_assets(s, payload) assert result["errors"] == [] assert result["created"] == 1 def test_import_without_external_id_key_still_works(engine): """老导出包没有这个字段,不能因此报错""" payload = [{ "name": "legacy", "asset_type": "vps", "provider": "vultr", "vps_detail": {"ip_address": "9.9.9.9"}, }] with Session(engine) as s: result = import_assets(s, payload) assert result["created"] == 1 and result["errors"] == [] assert _external_ids(engine)["legacy"] is None def test_import_all_or_nothing_on_failure(engine): """单事务语义:一条失败则整体回滚,不能留下半截数据""" _seed_vps(engine, name="good") payload = [ {"name": "ok-1", "asset_type": "vps", "provider": "vultr", "external_id": "i-1", "vps_detail": {"ip_address": "1.1.1.1"}}, {"name": "bad-1", "asset_type": "vps", "provider": "vultr"}, # 缺 vps_detail -> 400 ] with Session(engine) as s: result = import_assets(s, payload) assert result["created"] == 0 and result["updated"] == 0 assert len(result["errors"]) == 1 names = _external_ids(engine) assert "ok-1" not in names # 成功那条也回滚了 assert set(names) == {"good"} def test_round_trip_preserves_everything(engine): """完整往返:导出 -> 清空 -> 导入,external_id 与详情都在""" _seed_vps(engine, name="web-01", external_id="i-111") _seed_domain(engine, name="example.com", external_id="example.com") with Session(engine) as s: payload = export_assets(s)["assets"] _wipe(engine) with Session(engine) as s: result = import_assets(s, payload) assert result["errors"] == [] and result["created"] == 2 assert _external_ids(engine) == {"web-01": "i-111", "example.com": "example.com"} with Session(engine) as s: vps = s.exec(select(Asset).where(Asset.name == "web-01")).one() detail = s.exec(select(VPSDetail).where(VPSDetail.asset_id == vps.id)).one() assert detail.ip_address == "1.2.3.4"