fix: preserve subscriptions on panel lookup failures

This commit is contained in:
3252a8
2026-05-29 21:46:42 +03:00
parent 5e257c0d3a
commit 001e54cfe2
6 changed files with 564 additions and 7 deletions
+53
View File
@@ -77,6 +77,59 @@ class PanelApiServiceLoggingTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(service._request.await_count, 3)
async def test_get_user_by_uuid_lookup_returns_success_payload(self):
service = self._make_service()
service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}})
result = await service.get_user_by_uuid_lookup("user-uuid")
self.assertTrue(result["ok"])
self.assertFalse(result["not_found"])
self.assertIsNone(result["failure_reason"])
self.assertEqual(result["user"], {"uuid": "user-uuid"})
service._request.assert_awaited_once_with(
"GET",
"/users/user-uuid",
log_full_response=False,
)
async def test_get_user_by_uuid_lookup_keeps_transient_errors_separate_from_not_found(self):
service = self._make_service()
transient_response = {
"error": True,
"status_code": -1,
"message": "Connection error",
}
service._request = AsyncMock(return_value=transient_response)
result = await service.get_user_by_uuid_lookup("user-uuid")
self.assertFalse(result["ok"])
self.assertFalse(result["not_found"])
self.assertIsNone(result["user"])
self.assertIn("classification=panel_lookup_failed", result["failure_reason"])
self.assertIn("status_code=-1", result["failure_reason"])
self.assertIn("message=Connection error", result["failure_reason"])
self.assertEqual(result["response"], transient_response)
async def test_get_user_by_uuid_lookup_marks_confirmed_not_found(self):
service = self._make_service()
cases = [
{"error": True, "status_code": 404},
{"error": True, "status_code": 400, "details": {"errorCode": "A062"}},
]
for response in cases:
with self.subTest(response=response):
service._request = AsyncMock(return_value=response)
result = await service.get_user_by_uuid_lookup("missing-user")
self.assertFalse(result["ok"])
self.assertTrue(result["not_found"])
self.assertIsNone(result["user"])
self.assertIn("classification=confirmed_not_found", result["failure_reason"])
async def test_get_user_devices_uses_short_ttl_cache_and_disconnect_invalidates(self):
service = self._make_service()
service._request = AsyncMock(return_value={"response": [{"hwid": "device-1"}]})
@@ -2,6 +2,9 @@ import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from aiogram.exceptions import TelegramBadRequest
from aiogram.methods import SendMessage
from bot.services import subscription_lifecycle_notifications as lifecycle
from bot.services.subscription_lifecycle_notifications import (
SubscriptionLifecycleNotificationService,
@@ -41,6 +44,18 @@ class FakeBot:
)
class ChatNotFoundBot:
def __init__(self):
self.calls = []
async def send_message(self, chat_id, text, reply_markup=None):
self.calls.append((chat_id, text, reply_markup))
raise TelegramBadRequest(
method=SendMessage(chat_id=chat_id, text=text),
message="Bad Request: chat not found",
)
class FakeEmailService:
def __init__(self):
self.messages = []
@@ -172,3 +187,48 @@ def test_legacy_stage_key_suppresses_only_telegram(monkeypatch):
assert bot.messages == []
assert email_service.messages[0]["email"] == "user@example.test"
assert recorded == ["before_3d", "before_3d:email"]
def test_terminal_telegram_failure_is_recorded_to_avoid_retry_spam(monkeypatch):
recorded = []
async def fake_has(session, subscription_id, notification_key):
return notification_key in recorded
async def fake_record(session, subscription_id, notification_key, *, sent_at=None):
recorded.append(notification_key)
monkeypatch.setattr(lifecycle.subscription_dal, "has_subscription_notification", fake_has)
monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record)
bot = ChatNotFoundBot()
settings = _settings()
settings.email_auth_configured = False
service = SubscriptionLifecycleNotificationService(
settings,
bot,
FakeI18n(),
)
user = _user()
user.telegram_id = 777
user.email = ""
async def run():
return await service.send_stage(
object(),
_subscription(),
SubscriptionNotificationStage(
key="before_3d",
message_key="subscription_72h_notification",
days_left=3,
),
user=user,
telegram_markup="markup",
)
delivery = asyncio.run(run())
assert delivery.telegram_sent is False
assert delivery.email_sent is False
assert bot.calls[0][0] == 777
assert recorded == ["before_3d:telegram"]
+142 -1
View File
@@ -1,7 +1,7 @@
import json
import tempfile
import unittest
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
@@ -393,6 +393,147 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas
self.assertEqual(kwargs["payment_db_id"], 12)
class SubscriptionServiceActiveDetailsTests(unittest.IsolatedAsyncioTestCase):
def _local_active_sub(self) -> SimpleNamespace:
return SimpleNamespace(
subscription_id=7,
user_id=42,
panel_user_uuid="panel-user",
panel_subscription_uuid="short-uuid",
end_date=datetime.now(timezone.utc) + timedelta(days=10),
is_active=True,
status_from_panel="ACTIVE",
traffic_limit_bytes=1000,
traffic_used_bytes=100,
tariff_key=None,
tier_baseline_bytes=None,
topup_balance_bytes=0,
regular_bonus_bytes=0,
regular_unlimited_override=False,
premium_baseline_bytes=0,
premium_topup_balance_bytes=0,
premium_topup_used_bytes=0,
premium_used_bytes=0,
premium_bonus_bytes=0,
premium_unlimited_override=False,
premium_is_limited=False,
premium_period_start_at=None,
period_start_at=None,
is_throttled=False,
hwid_device_limit=None,
extra_hwid_devices=0,
)
async def test_get_active_subscription_details_preserves_local_subscription_on_panel_error(
self,
):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(_tariffs_config_payload(), tmpdir)
service = _make_service(settings)
service.panel_service.get_user_by_uuid_lookup = AsyncMock(
return_value={
"ok": False,
"user": None,
"not_found": False,
"failure_reason": "classification=panel_lookup_failed status_code=-1 "
"message=Connection error",
"response": {"error": True, "status_code": -1},
}
)
service.panel_service.get_subscription_link = AsyncMock(
return_value="https://panel.example.test/sub/short-uuid"
)
session = AsyncMock()
db_user = SimpleNamespace(
user_id=42,
panel_user_uuid="panel-user",
username="alice",
language_code="en",
)
local_sub = self._local_active_sub()
with (
patch(
"bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id",
AsyncMock(return_value=db_user),
),
patch(
"bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id",
AsyncMock(return_value=local_sub),
),
patch(
"bot.services.subscription_service_impl.lifecycle.subscription_dal.deactivate_all_user_subscriptions",
AsyncMock(),
) as deactivate_all,
patch(
"bot.services.subscription_service_impl.lifecycle.user_dal.update_user",
AsyncMock(),
) as update_user,
patch(
"bot.services.subscription_service_impl.lifecycle.logging.warning",
) as warning_log,
):
result = await service.get_active_subscription_details(session, user_id=42)
self.assertIsNotNone(result)
self.assertFalse(result["is_panel_data"])
self.assertEqual(result["end_date"], local_sub.end_date)
self.assertEqual(result["config_link"], "https://panel.example.test/sub/short-uuid")
deactivate_all.assert_not_awaited()
update_user.assert_not_awaited()
warning_text = " ".join(str(call) for call in warning_log.call_args_list)
self.assertIn("panel access/API problem", warning_text)
self.assertIn("status_code=-1", warning_text)
self.assertIn("Connection error", warning_text)
async def test_get_active_subscription_details_clears_link_only_when_panel_confirms_absent(
self,
):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(_tariffs_config_payload(), tmpdir)
service = _make_service(settings)
service.panel_service.get_user_by_uuid_lookup = AsyncMock(
return_value={
"ok": False,
"user": None,
"not_found": True,
"failure_reason": "classification=confirmed_not_found status_code=404",
"response": {"error": True, "status_code": 404},
}
)
session = AsyncMock()
db_user = SimpleNamespace(
user_id=42,
panel_user_uuid="panel-user",
username="alice",
language_code="en",
)
with (
patch(
"bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id",
AsyncMock(return_value=db_user),
),
patch(
"bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id",
AsyncMock(return_value=self._local_active_sub()),
),
patch(
"bot.services.subscription_service_impl.lifecycle.subscription_dal.deactivate_all_user_subscriptions",
AsyncMock(),
) as deactivate_all,
patch(
"bot.services.subscription_service_impl.lifecycle.user_dal.update_user",
AsyncMock(),
) as update_user,
):
result = await service.get_active_subscription_details(session, user_id=42)
self.assertIsNone(result)
deactivate_all.assert_awaited_once_with(session, 42)
update_user.assert_awaited_once_with(session, 42, {"panel_user_uuid": None})
class SubscriptionDalPayloadTests(unittest.TestCase):
def test_subscription_model_payload_drops_panel_only_keys(self):
payload = _subscription_model_payload(