Files
vps-manager/tests/test_stats_overview.py
gouki 46faadbcae feat(multicloud): 补齐上线前的多云管理缺口(适配器/动作层/DNS/定时同步)
评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐:

适配层
- 新增 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
2026-10-12 00:43:37 +00:00

184 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""资产总览统计测试(get_overview)
覆盖的核心问题:「本年支出」是只把填了费用的资产加起来的和。cost 默认 0,
所以没填费用和真的免费在数据上无法区分——合计因此天然是个下限,而不是准数。
前端必须能拿到缺口大小(year_cost_missing)才能如实说明,否则用户会把下限
当成实际支出。
顺带回归到期口径:已过期(overdue)与 30 天内到期(expiring_30)分开计数,
已注销资产两边都不进。
运行:.venv/bin/pytest tests/test_stats_overview.py -v
"""
from datetime import date, timedelta
import pytest
from sqlmodel import Session, SQLModel, create_engine
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, AssetStatus, AssetType
from app.services.asset_service import get_overview
@pytest.fixture()
def engine(tmp_path):
eng = create_engine(
f"sqlite:///{tmp_path / 'stats.db'}", connect_args={"check_same_thread": False}
)
SQLModel.metadata.create_all(eng)
yield eng
eng.dispose()
def _add(engine, name, *, expiry=None, cost=0.0, status=AssetStatus.ACTIVE,
asset_type=AssetType.VPS):
with Session(engine) as s:
s.add(Asset(
name=name, asset_type=asset_type, provider="vultr",
expiry_date=expiry, cost=cost, status=status,
))
s.commit()
def _overview(engine):
with Session(engine) as s:
return get_overview(s)
def test_empty_db_returns_all_contract_keys(engine):
"""空库也必须给出完整结构:/api/stats/overview 是前端总览页的数据源,
缺键会让 dashboard.js 渲染出 undefined"""
got = _overview(engine)
assert got == {
"total": 0,
"by_type": {},
"by_status": {},
"abnormal_count": 0,
"expiring_30": 0,
"overdue_count": 0,
"year_cost": 0,
"month_cost": 0,
"year_cost_missing": 0,
}
def test_year_cost_sums_only_filled_costs(engine):
today = date.today()
_add(engine, "paid", expiry=today, cost=12.5)
_add(engine, "also-paid", expiry=today + timedelta(days=3), cost=7.25)
got = _overview(engine)
assert got["year_cost"] == 19.75
assert got["year_cost_missing"] == 0
def test_unfilled_cost_is_counted_as_gap_not_zero_spend(engine):
"""cost 默认 0,没填的资产既不进合计、也不能悄悄消失"""
today = date.today()
_add(engine, "paid", expiry=today, cost=10.0)
_add(engine, "unfilled-a", expiry=today + timedelta(days=1))
_add(engine, "unfilled-b", expiry=today + timedelta(days=2))
got = _overview(engine)
assert got["year_cost"] == 10.0
assert got["year_cost_missing"] == 2
assert got["total"] == 3
def test_assets_without_expiry_do_not_enter_cost_window(engine):
"""没有到期日的资产不属于任何年度窗口:既不该加进合计,也不该算作缺口,
否则「另有 N 项未填费用」会把一批跟今年无关的资产算进去"""
_add(engine, "no-expiry-unfilled")
_add(engine, "no-expiry-paid", cost=99.0)
got = _overview(engine)
assert got["year_cost"] == 0
assert got["year_cost_missing"] == 0
assert got["total"] == 2
def test_other_year_expiry_excluded_from_cost(engine):
"""跨年资产不进本年合计,也不进本年缺口"""
this_year = date.today()
other_year = date(this_year.year + 1, 6, 15)
_add(engine, "this-year", expiry=this_year, cost=5.0)
_add(engine, "next-year-unfilled", expiry=other_year)
got = _overview(engine)
assert got["year_cost"] == 5.0
assert got["year_cost_missing"] == 0
def test_month_cost_only_current_month(engine):
today = date.today()
# 取本月内的另一天:若今天是 1 号则往后挪,避免算出上/下月的日期
same_month = today.replace(day=1) if today.day > 1 else today
next_month = (today.replace(day=1) + timedelta(days=32)).replace(day=2)
_add(engine, "this-month", expiry=same_month, cost=20.0)
_add(engine, "next-month", expiry=next_month, cost=30.0)
got = _overview(engine)
assert got["month_cost"] == 20.0
# 下月那笔仍属于本年
assert got["year_cost"] == 50.0
def test_year_cost_rounded_to_two_decimals(engine):
"""币种最小单位是分,浮点累加的 0.1+0.2 尾巴不能漏到界面上"""
today = date.today()
_add(engine, "a", expiry=today, cost=0.1)
_add(engine, "b", expiry=today, cost=0.2)
assert _overview(engine)["year_cost"] == 0.3
def test_overdue_and_expiring_counted_separately(engine):
"""已过期比 30 天内到期更该被看见,不能因为不在未来窗口里就从统计消失"""
today = date.today()
_add(engine, "overdue", expiry=today - timedelta(days=1))
_add(engine, "soon", expiry=today + timedelta(days=10))
_add(engine, "far", expiry=today + timedelta(days=90))
got = _overview(engine)
assert got["overdue_count"] == 1
assert got["expiring_30"] == 1
assert got["total"] == 3
def test_today_counts_as_expiring_not_overdue(engine):
"""到期日就是今天:还能续费,属于倒计时而不是已错过"""
_add(engine, "due-today", expiry=date.today())
got = _overview(engine)
assert got["expiring_30"] == 1
assert got["overdue_count"] == 0
def test_cancelled_assets_excluded_from_expiry_counts(engine):
"""已注销资产不该再触发续费提醒"""
today = date.today()
_add(engine, "cancelled-overdue", expiry=today - timedelta(days=5),
status=AssetStatus.CANCELLED)
_add(engine, "cancelled-soon", expiry=today + timedelta(days=5),
status=AssetStatus.CANCELLED)
got = _overview(engine)
assert got["overdue_count"] == 0
assert got["expiring_30"] == 0
# 但仍计入总数:台账要能查到已注销的历史资产
assert got["total"] == 2
def test_abnormal_counts_stopped_and_expired(engine):
_add(engine, "stopped", status=AssetStatus.STOPPED)
_add(engine, "expired", status=AssetStatus.EXPIRED)
_add(engine, "active", status=AssetStatus.ACTIVE)
got = _overview(engine)
assert got["abnormal_count"] == 2
assert got["by_status"] == {"stopped": 1, "expired": 1, "active": 1}
def test_by_type_uses_enum_values_not_names(engine):
"""前端按 'vps'/'domain' 匹配图标与文案,给出 'VPS' 会全部落到兜底分支"""
_add(engine, "v1", asset_type=AssetType.VPS)
_add(engine, "d1", asset_type=AssetType.DOMAIN)
_add(engine, "d2", asset_type=AssetType.DOMAIN)
got = _overview(engine)
assert got["by_type"] == {"vps": 1, "domain": 2}