feat(renewal): 提醒前核对平台真实状态,消除手工数据导致的误报
容错策略:核对失败/无可用通道时标记「未核对」并继续按本地数据提醒, 避免因核对链路故障漏掉真正到期的资产;已停止资产仅在 auto_renew=true 时提醒(停止实例不等于停止扣费)。新增 WHMCS 面板只读适配器, 用于 TotHost 这类没有开放 API 的平台。
This commit is contained in:
@@ -24,6 +24,7 @@ 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
|
||||
from app.services.verification_service import VerificationResult
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -36,11 +37,11 @@ def engine(tmp_path):
|
||||
eng.dispose()
|
||||
|
||||
|
||||
def _seed(engine, name, *, expiry, status=AssetStatus.ACTIVE, provider="vultr"):
|
||||
def _seed(engine, name, *, expiry, status=AssetStatus.ACTIVE, provider="vultr", auto_renew=False):
|
||||
with Session(engine) as s:
|
||||
s.add(Asset(
|
||||
name=name, asset_type=AssetType.VPS, provider=provider,
|
||||
expiry_date=expiry, status=status,
|
||||
expiry_date=expiry, status=status, auto_renew=auto_renew,
|
||||
))
|
||||
s.commit()
|
||||
|
||||
@@ -91,6 +92,27 @@ def test_get_expiring_assets_excludes_cancelled(engine):
|
||||
assert names == ["active"]
|
||||
|
||||
|
||||
def test_get_expiring_assets_stopped_without_auto_renew_excluded(engine):
|
||||
"""已停止且未开自动续费的资产不再提醒"""
|
||||
today = date.today()
|
||||
_seed(engine, "stopped", expiry=today + timedelta(days=5), status=AssetStatus.STOPPED)
|
||||
with Session(engine) as s:
|
||||
result = renewal_service.get_expiring_assets(s, threshold_days=30)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_get_expiring_assets_stopped_with_auto_renew_included(engine):
|
||||
"""已停止但自动续费仍开着,可能继续扣费,必须提醒"""
|
||||
today = date.today()
|
||||
_seed(
|
||||
engine, "stopped-autorenew", expiry=today + timedelta(days=5),
|
||||
status=AssetStatus.STOPPED, auto_renew=True,
|
||||
)
|
||||
with Session(engine) as s:
|
||||
result = renewal_service.get_expiring_assets(s, threshold_days=30)
|
||||
assert [a.name for a, _ in result] == ["stopped-autorenew"]
|
||||
|
||||
|
||||
def test_get_expiring_assets_no_expiry_date_skipped(engine):
|
||||
"""未填到期日的资产不出现在到期列表"""
|
||||
_seed(engine, "no-expiry", expiry=None)
|
||||
@@ -132,6 +154,26 @@ def test_build_message_no_overdue():
|
||||
assert "共 1 项" in msg
|
||||
|
||||
|
||||
def test_build_message_labels_verification_source():
|
||||
"""消息中区分「已核对」与「未核对」,并给出核对汇总"""
|
||||
today = date.today()
|
||||
a1 = Asset(id=1, name="vps-1", provider="tothost", expiry_date=today - timedelta(days=1))
|
||||
a2 = Asset(id=2, name="vps-2", provider="agnes", expiry_date=today + timedelta(days=5))
|
||||
verification = {
|
||||
1: VerificationResult(
|
||||
asset_id=1, verified=True, source="TotHost 面板",
|
||||
detail="服务 TOT K11 vpn.host:可用,下次到期 2026-11-25",
|
||||
),
|
||||
2: VerificationResult(asset_id=2, verified=False, detail="平台「Agnes」未接入自动核对"),
|
||||
}
|
||||
msg = renewal_service.build_renewal_message(
|
||||
[(a1, -1), (a2, 5)], threshold_days=30, verification=verification
|
||||
)
|
||||
assert "已核对:服务 TOT K11 vpn.host" in msg
|
||||
assert "未核对:平台「Agnes」未接入自动核对" in msg
|
||||
assert "核对:已核对 1 项,未核对 1 项" in msg
|
||||
|
||||
|
||||
# ----------------------------- check_renewals ----------------------------- #
|
||||
def test_check_renewals_no_send(engine):
|
||||
"""send=False 时不触发通知,仅返回预览"""
|
||||
@@ -172,3 +214,48 @@ def test_check_renewals_default_threshold_from_config(engine):
|
||||
with Session(engine) as s:
|
||||
result = renewal_service.check_renewals(s, send=False)
|
||||
assert result["threshold_days"] == renewal_service.settings.RENEWAL_THRESHOLD_DAYS
|
||||
|
||||
|
||||
def test_check_renewals_verification_suppresses_false_alarm(engine):
|
||||
"""核对发现已续费到 90 天后时,本轮不再提醒"""
|
||||
today = date.today()
|
||||
_seed(engine, "renewed", expiry=today - timedelta(days=1))
|
||||
|
||||
def fake_verify(session, assets):
|
||||
results = {}
|
||||
for asset in assets:
|
||||
asset.expiry_date = today + timedelta(days=90)
|
||||
session.add(asset)
|
||||
results[asset.id] = VerificationResult(
|
||||
asset_id=asset.id, verified=True, source="TotHost 面板",
|
||||
expiry_date=asset.expiry_date, detail="已续费",
|
||||
)
|
||||
session.commit()
|
||||
return results
|
||||
|
||||
with Session(engine) as s:
|
||||
with patch.object(
|
||||
renewal_service.verification_service, "verify_assets", side_effect=fake_verify
|
||||
) as mock_verify:
|
||||
result = renewal_service.check_renewals(s, threshold_days=30, send=False)
|
||||
mock_verify.assert_called_once()
|
||||
assert result["expiring_count"] == 0
|
||||
assert result["message"] == ""
|
||||
assert result["notifications"] == []
|
||||
|
||||
|
||||
def test_check_renewals_can_disable_verification(engine):
|
||||
"""verify=False 时不调用核对服务"""
|
||||
today = date.today()
|
||||
_seed(engine, "soon", expiry=today + timedelta(days=5))
|
||||
with Session(engine) as s:
|
||||
with patch.object(
|
||||
renewal_service.verification_service, "verify_assets",
|
||||
side_effect=AssertionError("不应调用核对"),
|
||||
) as mock_verify:
|
||||
result = renewal_service.check_renewals(
|
||||
s, threshold_days=30, send=False, verify=False
|
||||
)
|
||||
mock_verify.assert_not_called()
|
||||
assert result["expiring_count"] == 1
|
||||
assert result["verification"] is None
|
||||
|
||||
@@ -93,7 +93,7 @@ def test_script_summary_prints_every_key_the_service_returns(script, capsys):
|
||||
assert rc == 0
|
||||
assert "[云资产同步]" in out
|
||||
for fragment in ("账号 0", "成功 0", "失败 0", "新增 0", "更新 0",
|
||||
"跳过(无凭证) 0", "跳过(无适配器) 0"):
|
||||
"跳过(无凭证) 0", "跳过(无适配器) 0", "跳过(面板核对) 0"):
|
||||
assert fragment in out, f"摘要缺少 {fragment}:{out!r}"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""verification_service 测试:发送前平台状态核对
|
||||
|
||||
覆盖:
|
||||
- WHMCS 面板:按 external_id 更新到期日/状态
|
||||
- WHMCS 面板:服务已从面板消失 -> 标记已注销
|
||||
- WHMCS 面板:账号下唯一未关联资产 + 唯一在用服务 -> 自动补 external_id
|
||||
- 云厂商 API:按 external_id 更新到期日/状态
|
||||
- 平台无适配器 -> 未核对
|
||||
|
||||
网络适配器全部用 fake 替换,不发起真实请求。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
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.adapters.base import NormalizedVPS
|
||||
from app.models.asset import Account, Asset, AssetStatus, AssetType, VPSDetail
|
||||
from app.models.provider import Provider, ProviderCategory
|
||||
from app.services import sync_service, verification_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_provider(session, sdk_type="whmcs-panel"):
|
||||
provider = Provider(
|
||||
slug="tothost", name="TotHost", category=ProviderCategory.VPS,
|
||||
services="vps", sdk_type=sdk_type,
|
||||
console_url="https://billing.example.com/clientarea.php",
|
||||
)
|
||||
session.add(provider)
|
||||
session.commit()
|
||||
session.refresh(provider)
|
||||
return provider
|
||||
|
||||
|
||||
def _seed_account(session, provider):
|
||||
account = Account(name="user@example.com", platform=provider.slug)
|
||||
session.add(account)
|
||||
session.commit()
|
||||
session.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
def _seed_asset(session, provider, account, *, name="vn-200", ip="103.70.115.57",
|
||||
expiry=date(2026, 10, 6), external_id=None):
|
||||
asset = Asset(
|
||||
name=name, asset_type=AssetType.VPS, provider=provider.slug,
|
||||
provider_id=provider.id, account_id=account.id if account else None,
|
||||
expiry_date=expiry, status=AssetStatus.ACTIVE, external_id=external_id,
|
||||
)
|
||||
session.add(asset)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
session.add(VPSDetail(asset_id=asset.id, ip_address=ip))
|
||||
session.commit()
|
||||
return asset
|
||||
|
||||
|
||||
class FakeWHMCS:
|
||||
def __init__(self, services):
|
||||
self.services = services
|
||||
|
||||
def fetch_services(self, include_terminated=True):
|
||||
if include_terminated:
|
||||
return self.services
|
||||
return [s for s in self.services if s["status"] != "cancelled"]
|
||||
|
||||
|
||||
def _service(sid="36979", domain="remote-dev", expiry=date(2027, 1, 7), status="active"):
|
||||
return {
|
||||
"id": sid, "product": "TOT K11", "domain": domain,
|
||||
"label": f"TOT K11 {domain}", "expiry_date": expiry, "status": status,
|
||||
"status_class": status, "status_text": "可用", "amount": None, "cycle": "季繳",
|
||||
}
|
||||
|
||||
|
||||
def test_whmcs_updates_expiry_and_links_external_id(engine, monkeypatch):
|
||||
with Session(engine) as s:
|
||||
provider = _seed_provider(s)
|
||||
account = _seed_account(s, provider)
|
||||
asset = _seed_asset(s, provider, account)
|
||||
|
||||
fake = FakeWHMCS([_service()])
|
||||
monkeypatch.setattr(
|
||||
sync_service, "_build_adapter_for_account", lambda session, acct: (provider, fake)
|
||||
)
|
||||
|
||||
results = verification_service.verify_assets(s, [asset])
|
||||
s.refresh(asset)
|
||||
snapshot = (asset.expiry_date, asset.status, asset.external_id)
|
||||
|
||||
result = results[asset.id]
|
||||
assert result.verified is True
|
||||
assert result.source == "TotHost 面板"
|
||||
assert snapshot == (date(2027, 1, 7), AssetStatus.ACTIVE, "36979")
|
||||
|
||||
|
||||
def test_whmcs_missing_service_marks_cancelled(engine, monkeypatch):
|
||||
with Session(engine) as s:
|
||||
provider = _seed_provider(s)
|
||||
account = _seed_account(s, provider)
|
||||
asset = _seed_asset(s, provider, account, external_id="99999")
|
||||
|
||||
monkeypatch.setattr(
|
||||
sync_service, "_build_adapter_for_account",
|
||||
lambda session, acct: (provider, FakeWHMCS([_service()])),
|
||||
)
|
||||
|
||||
results = verification_service.verify_assets(s, [asset])
|
||||
s.refresh(asset)
|
||||
|
||||
assert results[asset.id].verified is True
|
||||
assert results[asset.id].status == AssetStatus.CANCELLED
|
||||
assert asset.status == AssetStatus.CANCELLED
|
||||
assert "已无该服务" in results[asset.id].detail
|
||||
|
||||
|
||||
def test_whmcs_without_account_is_unverified(engine, monkeypatch):
|
||||
with Session(engine) as s:
|
||||
provider = _seed_provider(s)
|
||||
asset = _seed_asset(s, provider, None)
|
||||
|
||||
results = verification_service.verify_assets(s, [asset])
|
||||
|
||||
assert results[asset.id].verified is False
|
||||
assert "未关联账号" in results[asset.id].detail
|
||||
|
||||
|
||||
def test_whmcs_ambiguous_unlinked_asset_is_unverified(engine, monkeypatch):
|
||||
"""同账号有两个未关联资产、面板只有一个在用服务时不做猜测"""
|
||||
with Session(engine) as s:
|
||||
provider = _seed_provider(s)
|
||||
account = _seed_account(s, provider)
|
||||
a1 = _seed_asset(s, provider, account, name="vps-a", ip="1.1.1.1")
|
||||
a2 = _seed_asset(s, provider, account, name="vps-b", ip="2.2.2.2")
|
||||
|
||||
monkeypatch.setattr(
|
||||
sync_service, "_build_adapter_for_account",
|
||||
lambda session, acct: (provider, FakeWHMCS([_service()])),
|
||||
)
|
||||
|
||||
results = verification_service.verify_assets(s, [a1, a2])
|
||||
|
||||
assert results[a1.id].verified is False
|
||||
assert results[a2.id].verified is False
|
||||
assert "无法与面板服务对应" in results[a1.id].detail
|
||||
|
||||
|
||||
class FakeAPI:
|
||||
def __init__(self, vps):
|
||||
self._vps = vps
|
||||
|
||||
def capabilities(self):
|
||||
return {"list_vps": True, "list_domains": False}
|
||||
|
||||
def list_vps(self):
|
||||
return self._vps
|
||||
|
||||
|
||||
def test_api_updates_expiry_and_status(engine, monkeypatch):
|
||||
with Session(engine) as s:
|
||||
provider = _seed_provider(s, sdk_type="vultr-api")
|
||||
account = _seed_account(s, provider)
|
||||
asset = _seed_asset(s, provider, account, external_id="abc")
|
||||
|
||||
fake = FakeAPI([NormalizedVPS(
|
||||
external_id="abc", name="vps", ip_address="103.70.115.57",
|
||||
status="active", expiry_date=date(2027, 5, 1),
|
||||
)])
|
||||
monkeypatch.setattr(
|
||||
sync_service, "_build_adapter_for_account", lambda session, acct: (provider, fake)
|
||||
)
|
||||
|
||||
results = verification_service.verify_assets(s, [asset])
|
||||
s.refresh(asset)
|
||||
snapshot = (asset.expiry_date, asset.status)
|
||||
|
||||
assert results[asset.id].verified is True
|
||||
assert results[asset.id].source == "TotHost API"
|
||||
assert snapshot == (date(2027, 5, 1), AssetStatus.ACTIVE)
|
||||
|
||||
|
||||
def test_unsupported_provider_is_unverified(engine):
|
||||
with Session(engine) as s:
|
||||
provider = _seed_provider(s, sdk_type=None)
|
||||
account = _seed_account(s, provider)
|
||||
asset = _seed_asset(s, provider, account)
|
||||
|
||||
results = verification_service.verify_assets(s, [asset])
|
||||
|
||||
assert results[asset.id].verified is False
|
||||
assert "未接入自动核对" in results[asset.id].detail
|
||||
@@ -0,0 +1,140 @@
|
||||
"""WHMCS 面板适配器测试:页面解析与状态映射
|
||||
|
||||
使用真实客户区结构的 HTML 片段,不发起网络请求。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.whmcs import WHMCSAdapter, WHMCSError
|
||||
|
||||
SERVICES_HTML = """
|
||||
<table id="tableServicesList" class="table table-list w-hidden">
|
||||
<thead><tr><th></th><th>產品 / 服務</th><th>價格</th><th>下次到期日</th><th>狀態</th></tr></thead>
|
||||
<tbody>
|
||||
<tr onclick="clickableSafeRedirect(event, 'clientarea.php?action=productdetails&id=36979', false)">
|
||||
<td data-element-id="36979" data-type="service" data-domain="remote-dev"></td>
|
||||
<td><strong>TOT K11</strong><br /><a href="http://remote-dev">remote-dev</a></td>
|
||||
<td data-order="226950.00">226,950VND <small class="text-muted">季繳</small></td>
|
||||
<td><span class="w-hidden">2027-01-07</span>星期四, 一月 7第, 2027</td>
|
||||
<td><span class="label status status-active">可用</span></td>
|
||||
</tr>
|
||||
<tr onclick="clickableSafeRedirect(event, 'clientarea.php?action=productdetails&id=28911', false)">
|
||||
<td data-element-id="28911" data-type="service" data-domain="vpn.host"></td>
|
||||
<td><strong>TOT K11</strong><br /><a href="http://vpn.host">vpn.host</a></td>
|
||||
<td data-order="3.03">$3.03 <small class="text-muted">月繳</small></td>
|
||||
<td><span class="w-hidden">2026-11-25</span>星期三, 十一月 25第, 2026</td>
|
||||
<td><span class="label status status-active">可用</span></td>
|
||||
</tr>
|
||||
<tr onclick="clickableSafeRedirect(event, 'clientarea.php?action=productdetails&id=36980', false)">
|
||||
<td data-element-id="36980" data-type="service" data-domain="123.31.29.104"></td>
|
||||
<td><strong>TOT D12</strong><br /><a href="http://123.31.29.104">123.31.29.104</a></td>
|
||||
<td data-order="275000.00">275,000VND <small class="text-muted">月繳</small></td>
|
||||
<td><span class="w-hidden">2026-06-11</span>星期四, 六月 11第, 2026</td>
|
||||
<td><span class="label status status-terminated">已刪除</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-element-id="36981" data-type="service" data-domain="paused.host"></td>
|
||||
<td><strong>TOT K11</strong><br /><a href="http://paused.host">paused.host</a></td>
|
||||
<td data-order="3.03">$3.03 <small class="text-muted">月繳</small></td>
|
||||
<td><span class="w-hidden">2026-12-01</span>星期二, 十二月 1第, 2026</td>
|
||||
<td><span class="label status status-suspended">已暫停</span></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
"""
|
||||
|
||||
|
||||
def _adapter():
|
||||
return WHMCSAdapter({
|
||||
"base_url": "https://billing.example.com/clientarea.php",
|
||||
"username": "user@example.com",
|
||||
"password": "secret",
|
||||
})
|
||||
|
||||
|
||||
def test_parse_services_extracts_all_fields():
|
||||
services = WHMCSAdapter.parse_services(SERVICES_HTML)
|
||||
assert [s["id"] for s in services] == ["36979", "28911", "36980", "36981"]
|
||||
|
||||
first = services[0]
|
||||
assert first["product"] == "TOT K11"
|
||||
assert first["domain"] == "remote-dev"
|
||||
assert first["label"] == "TOT K11 remote-dev"
|
||||
assert first["expiry_date"] == date(2027, 1, 7)
|
||||
assert first["status"] == "active"
|
||||
assert first["status_text"] == "可用"
|
||||
assert first["amount"] == 226950.0
|
||||
assert first["cycle"] == "季繳"
|
||||
|
||||
|
||||
def test_parse_services_maps_whmcs_status_classes():
|
||||
services = {s["id"]: s for s in WHMCSAdapter.parse_services(SERVICES_HTML)}
|
||||
assert services["36979"]["status"] == "active"
|
||||
assert services["36980"]["status"] == "cancelled"
|
||||
assert services["36981"]["status"] == "stopped"
|
||||
|
||||
|
||||
def test_parse_services_requires_service_table():
|
||||
with pytest.raises(WHMCSError):
|
||||
WHMCSAdapter.parse_services("<html><body>no table</body></html>")
|
||||
|
||||
|
||||
def test_list_vps_filters_terminated_and_keeps_ip(monkeypatch):
|
||||
adapter = _adapter()
|
||||
services = WHMCSAdapter.parse_services(SERVICES_HTML)
|
||||
monkeypatch.setattr(adapter, "fetch_services", lambda include_terminated=True: [
|
||||
s for s in services if include_terminated or s["status"] != "cancelled"
|
||||
])
|
||||
|
||||
vps = adapter.list_vps()
|
||||
assert [v.external_id for v in vps] == ["36979", "28911", "36981"]
|
||||
|
||||
by_id = {v.external_id: v for v in vps}
|
||||
assert by_id["36979"].name == "TOT K11 remote-dev"
|
||||
assert by_id["36979"].ip_address == "" # 主机名不是 IP
|
||||
assert by_id["36979"].expiry_date == date(2027, 1, 7)
|
||||
assert "36980" not in by_id # 已删除项不出现在 list_vps
|
||||
|
||||
|
||||
def test_extract_token_supports_attribute_orders():
|
||||
assert WHMCSAdapter._extract_token('<input type="hidden" name="token" value="abc">') == "abc"
|
||||
assert WHMCSAdapter._extract_token('<input value="xyz" name="token">') == "xyz"
|
||||
|
||||
|
||||
def test_extract_token_prefers_login_form():
|
||||
"""页面上有多个带 token 的表单时,必须取登录表单里的那个"""
|
||||
page = """
|
||||
<form action="/clientarea.php?action=changepassword" method="post">
|
||||
<input type="hidden" name="token" value="wrong">
|
||||
</form>
|
||||
<form action="/login" method="post">
|
||||
<input type="hidden" name="token" value="right">
|
||||
</form>
|
||||
"""
|
||||
assert WHMCSAdapter._extract_token(page) == "right"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [
|
||||
'<a href="/logout.php">Logout</a>',
|
||||
'<a href="/logout.php">退出登录</a>',
|
||||
'<a href="/logout.php">登出</a>',
|
||||
'<a href="/logout.php">ログアウト</a>',
|
||||
])
|
||||
def test_logged_in_markers_cover_localized_themes(body):
|
||||
"""主题本地化后不再输出英文 Logout,误判会让核对永远失败"""
|
||||
from app.adapters.whmcs import _LOGGED_IN_RE
|
||||
|
||||
assert _LOGGED_IN_RE.search(body)
|
||||
|
||||
|
||||
def test_logged_in_marker_ignores_plain_text():
|
||||
from app.adapters.whmcs import _LOGGED_IN_RE
|
||||
|
||||
assert not _LOGGED_IN_RE.search("<p>请勿退出页面</p>")
|
||||
|
||||
|
||||
|
||||
def test_adapter_requires_base_url():
|
||||
with pytest.raises(WHMCSError):
|
||||
WHMCSAdapter({"username": "u", "password": "p"})
|
||||
Reference in New Issue
Block a user