容错策略:核对失败/无可用通道时标记「未核对」并继续按本地数据提醒, 避免因核对链路故障漏掉真正到期的资产;已停止资产仅在 auto_renew=true 时提醒(停止实例不等于停止扣费)。新增 WHMCS 面板只读适配器, 用于 TotHost 这类没有开放 API 的平台。
141 lines
5.6 KiB
Python
141 lines
5.6 KiB
Python
"""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"})
|