"""renewal_service 测试:续费检查与提醒消息生成 覆盖: - get_expiring_assets: 到期资产查询(含已过期、排除已注销、按天数升序) - day_text: 到期文案生成(已过期/今天/未来) - build_renewal_message: 消息拼装(有过期+未过期混合、全部未过期、空列表) - check_renewals: 完整流程(send=True 触发通知 / send=False 仅预览 / 默认阈值取配置) 全程 tmp_path + create_engine 临时库,mock 通知服务避免真实发送。 运行:.venv/bin/pytest tests/test_renewal_service.py -v """ from datetime import date, timedelta from unittest.mock import patch 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 import renewal_service @pytest.fixture() def engine(tmp_path): eng = create_engine( f"sqlite:///{tmp_path / 'assets.db'}", connect_args={"check_same_thread": False} ) SQLModel.metadata.create_all(eng) yield eng eng.dispose() def _seed(engine, name, *, expiry, status=AssetStatus.ACTIVE, provider="vultr"): with Session(engine) as s: s.add(Asset( name=name, asset_type=AssetType.VPS, provider=provider, expiry_date=expiry, status=status, )) s.commit() # ----------------------------- day_text ----------------------------- # def test_day_text_overdue(): assert renewal_service.day_text(-5) == "已过期 5 天" assert renewal_service.day_text(-1) == "已过期 1 天" def test_day_text_today(): assert renewal_service.day_text(0) == "今天到期" def test_day_text_future(): assert renewal_service.day_text(1) == "1 天后" assert renewal_service.day_text(30) == "30 天后" # ----------------------------- get_expiring_assets ----------------------------- # def test_get_expiring_assets_empty(engine): with Session(engine) as s: result = renewal_service.get_expiring_assets(s, threshold_days=30) assert result == [] def test_get_expiring_assets_includes_overdue_and_sorted(engine): """已过期资产也纳入提醒(不能因过了到期日就消失),按剩余天数升序""" today = date.today() _seed(engine, "overdue", expiry=today - timedelta(days=3)) _seed(engine, "soon", expiry=today + timedelta(days=5)) _seed(engine, "far", expiry=today + timedelta(days=90)) with Session(engine) as s: result = renewal_service.get_expiring_assets(s, threshold_days=30) names = [a.name for a, _ in result] assert names == ["overdue", "soon"] assert result[0][1] < result[1][1] # -3 < 5 def test_get_expiring_assets_excludes_cancelled(engine): """已注销资产不参与续费提醒""" today = date.today() _seed(engine, "active", expiry=today + timedelta(days=5)) _seed(engine, "cancelled", expiry=today + timedelta(days=5), status=AssetStatus.CANCELLED) with Session(engine) as s: result = renewal_service.get_expiring_assets(s, threshold_days=30) names = [a.name for a, _ in result] assert names == ["active"] def test_get_expiring_assets_no_expiry_date_skipped(engine): """未填到期日的资产不出现在到期列表""" _seed(engine, "no-expiry", expiry=None) # _seed 不支持 None expiry,直接插入 with Session(engine) as s: s.add(Asset(name="no-expiry", asset_type=AssetType.VPS, provider="vultr")) s.commit() result = renewal_service.get_expiring_assets(s, threshold_days=30) assert result == [] # ----------------------------- build_renewal_message ----------------------------- # def test_build_message_empty(): assert renewal_service.build_renewal_message([], 30) == "" def test_build_message_with_overdue(): """混合已过期和即将到期时,消息头部区分两类""" today = date.today() a1 = Asset(name="vps-1", provider="vultr", expiry_date=today - timedelta(days=2)) a2 = Asset(name="vps-2", provider="vultr", expiry_date=today + timedelta(days=10)) expiring = [(a1, -2), (a2, 10)] msg = renewal_service.build_renewal_message(expiring, threshold_days=30) assert "已过期 1 项" in msg assert "30 天内到期 1 项" in msg assert "vps-1" in msg assert "vps-2" in msg assert "已过期 2 天" in msg assert "10 天后" in msg def test_build_message_no_overdue(): """全部未过期时头部只显示常规文案""" today = date.today() a1 = Asset(name="vps-1", provider="vultr", expiry_date=today + timedelta(days=5)) expiring = [(a1, 5)] msg = renewal_service.build_renewal_message(expiring, threshold_days=30) assert "已过期" not in msg assert "共 1 项" in msg # ----------------------------- check_renewals ----------------------------- # def test_check_renewals_no_send(engine): """send=False 时不触发通知,仅返回预览""" today = date.today() _seed(engine, "soon", expiry=today + timedelta(days=5)) with Session(engine) as s: result = renewal_service.check_renewals(s, threshold_days=30, send=False) assert result["expiring_count"] == 1 assert result["threshold_days"] == 30 assert result["notifications"] == [] assert result["expiring"][0]["name"] == "soon" assert result["expiring"][0]["days"] == 5 def test_check_renewals_send_with_no_expiring(engine): """无到期资产时不发送通知""" with Session(engine) as s: result = renewal_service.check_renewals(s, threshold_days=30, send=True) assert result["expiring_count"] == 0 assert result["message"] == "" assert result["notifications"] == [] def test_check_renewals_send_dispatches_notification(engine): """send=True 且有到期资产时调用通知服务""" today = date.today() _seed(engine, "soon", expiry=today + timedelta(days=3)) with Session(engine) as s: with patch.object(renewal_service.notification_service, "notify", return_value=[{"ok": True}]) as mock_notify: result = renewal_service.check_renewals(s, threshold_days=30, send=True) mock_notify.assert_called_once() assert result["expiring_count"] == 1 assert result["notifications"] == [{"ok": True}] def test_check_renewals_default_threshold_from_config(engine): """threshold_days=None 时从 settings 读取默认值""" with Session(engine) as s: result = renewal_service.check_renewals(s, send=False) assert result["threshold_days"] == renewal_service.settings.RENEWAL_THRESHOLD_DAYS