Files
vps-manager/tests/test_notification.py
T
gouki 61372f7ceb fix(review): 修复全量审查发现的域名到期日链路、AI 资产挂靠迁移与 2FA 取码缺陷
92 文件全量代码审查后的高危/中危修复,每条都带回归测试,并在未修复的 HEAD 上
反向确认过「测试确实会失败」。

- H1 域名同步整批失败:NormalizedDomain.expiry_date 契约声明为字符串,Cloudflare
  适配器把 Registrar 的 ISO 时间戳截成 "YYYY-MM-DD" 直接赋给 Date 列,写库抛
  TypeError;而 _sync_domains 的 commit 在循环外,一个域名就让整批全量回滚
  (Cloudflare 域名实际从未同步成功)。现统一契约:base.py 新增 parse_iso_date,
  适配器产出 date,_sync_domains 写库前再归一一次以兼容仍返回字符串的适配器
- M9 Vultr / DigitalOcean 的域名到期日此前完全没映射,这两家域名永远进不了续费提醒
- M1 同步失败变 500:能力分支写库失败后未回滚,Session 停在 pending-rollback,
  随后更新 last_synced_at 的 commit 抛 PendingRollbackError,真实错误被 500 掩盖
- H2 AI 资产挂靠迁移静默零命中:迁移 SQL 用枚举值 'ai_agent' 匹配 Enum 列,而
  SQLAlchemy Enum 存的是成员名 'AI_AGENT',该分支从未生效(6 个 AI 资产的
  account_id 全为 NULL)。改用成员名并加日志留痕;账号命名沿用步骤 1 的
  「{slug}-默认」约定,避免建出与既有账号语义重复、且没有凭证的空账号
  (dry-run 实测:deepseek 资产复用已有且有凭证的 deepseek-默认)
- H3 2FA 动态码不刷新:otpState 全局常驻、收起只清 timer 不删条目,「不存在才拉」
  的守卫导致再次展开显示上一次的旧码且倒计时冻结在 0。改为每次展开都重取;
  stopOtp 同时清空旧码(避免重展开瞬间闪过已失效的码),并加请求代号,防止
  「请求在途时收起」后返回的响应又重启轮询
- M10 账号弹窗关闭不清理:外层用 v-if 关闭并不卸载组件,onUnmounted 不触发,
  OTP 轮询定时器与明文密码驻留后台。关闭时显式调 closePwd
- M2 提醒发不出去:Telegram 用 parse_mode=HTML,资产名含 < > & 时整条消息被 400
  拒收;纯文本提醒不需要任何标签,去掉 parse_mode,并把拒收正文带回便于排查
- M3 负数天文案:cbb32ea 已修好提醒消息正文,本次补齐仍在直出负数的两处
  (check_renewals.py 输出、设置页预览),后端抽出 renewal_service.day_text 统一,
  前端走 Fmt.countdownText

验证:pytest 94 passed(新增 tests/test_domain_expiry.py、tests/test_notification.py);
本地起服务对 H2 迁移做真实 dry-run(6 个 AI 资产挂到 4 个账号、无重复账号、幂等、
非 AI 资产零影响,迁移前已备份);浏览器端以 otpState 状态断言验证 H3/M10,
设置页续费预览确认已过期项显示「已过期 N 天」。
2026-09-22 22:26:42 +00:00

114 lines
4.0 KiB
Python
Raw 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.
"""提醒送达与到期文案测试
覆盖两处回归:
1. Telegram 不能带 parse_mode=HTML:消息里嵌的是资产名/平台名等用户数据,
含 < > & 时整条消息会被 Telegram 拒收(400),提醒静默发不出去;
失败时还必须把响应正文带回,否则只看到状态码根本无从排查。
2. days 可以为负(已过期),文案统一走 renewal_service.day_text,
不能直出「-37 天后到期」;前端对应 Fmt.countdownText,两边一致。
运行:.venv/bin/pytest tests/test_notification.py -v
"""
import httpx
import pytest
from app.core.config import settings
from app.services import notification_service, renewal_service
class _OkResp:
status_code = 200
text = "ok"
def raise_for_status(self):
return None
def test_send_telegram_omits_parse_mode(monkeypatch):
"""用户数据里的 < > & 不能让整条提醒被拒收"""
captured = {}
def fake_post(url, json=None, timeout=None):
captured["url"] = url
captured["payload"] = json
return _OkResp()
monkeypatch.setattr(notification_service.httpx, "post", fake_post)
monkeypatch.setattr(settings, "TELEGRAM_BOT_TOKEN", "tok")
monkeypatch.setattr(settings, "TELEGRAM_CHAT_ID", "123")
text = "⏰ 资产续费提醒\n• <测试&资产> [a&b] (已过期 3 天) 到期 2026-08-15"
result = notification_service.send_telegram(text)
assert result["ok"] is True
# 关键:不再声明 HTML 解析模式,纯文本原样送达
assert "parse_mode" not in captured["payload"]
assert captured["payload"]["text"] == text
assert captured["payload"]["chat_id"] == "123"
assert captured["payload"]["disable_web_page_preview"] is True
def test_send_telegram_failure_returns_response_body(monkeypatch):
"""拒收原因在响应体里,只留状态码无法排查"""
request = httpx.Request("POST", "https://api.telegram.org/bottok/sendMessage")
response = httpx.Response(400, request=request, text='{"description":"chat not found"}')
def fake_post(url, json=None, timeout=None):
raise httpx.HTTPStatusError("400 Bad Request", request=request, response=response)
monkeypatch.setattr(notification_service.httpx, "post", fake_post)
monkeypatch.setattr(settings, "TELEGRAM_BOT_TOKEN", "tok")
monkeypatch.setattr(settings, "TELEGRAM_CHAT_ID", "123")
result = notification_service.send_telegram("x")
assert result["ok"] is False
assert result["channel"] == "telegram"
assert "400" in result["message"]
assert "chat not found" in result["message"]
def test_send_telegram_skips_when_unconfigured(monkeypatch):
monkeypatch.setattr(settings, "TELEGRAM_BOT_TOKEN", "")
result = notification_service.send_telegram("x")
assert result["ok"] is False
assert "未配置" in result["message"]
@pytest.mark.parametrize(
"days,expected",
[(-37, "已过期 37 天"), (-1, "已过期 1 天"), (0, "今天到期"), (1, "1 天后"), (30, "30 天后")],
)
def test_day_text_never_prints_negative(days, expected):
assert renewal_service.day_text(days) == expected
class _FakeAsset:
"""build_renewal_message 只用到 name / provider / expiry_date"""
def __init__(self, name, provider, expiry_date):
self.name = name
self.provider = provider
self.expiry_date = expiry_date
def test_renewal_message_separates_overdue():
"""已过期与即将到期分开计数,且不出现「-37 天后」这类文案"""
msg = renewal_service.build_renewal_message(
[
(_FakeAsset("ChatGPT Plus", "openai", "2026-08-15"), -37),
(_FakeAsset("vps-1", "vultr", "2026-10-01"), 11),
],
threshold_days=30,
)
assert "已过期 1 项" in msg
assert "30 天内到期 1 项" in msg
assert "(已过期 37 天)" in msg
assert "(11 天后)" in msg
assert "-37" not in msg
def test_renewal_message_empty_when_nothing_expiring():
assert renewal_service.build_renewal_message([], threshold_days=30) == ""