zapretgui/tests/test_premium_status_runtime.py
loop-uh 3bd2973c7e
All checks were successful
Zapret source guards / validate (push) Successful in 9s
Premium: подписка больше не «пропадает» после перезапуска и обновления — статус сам сверяется с сервером при запуске и дальше по расписанию, а привязка по коду подхватывается без кнопки «Обновить статус»
Причина: в сеть за статусом ходила только страница Premium (по кнопке и
пока открыта). Запуск читал один сохранённый подписанный ответ сервера,
а он живёт 7 дней — после этого любая перезагрузка показывала Free.
Опрос после создания кода был выключен, если на устройстве уже лежал
старый токен, и останавливался при уходе со страницы.

Теперь проверкой владеет один объект на всё время работы программы
(donater/status_runtime.py): сохранённый статус при запуске, затем сервер,
дальше повтор по правилам donater/status_schedule.py; свежий код
опрашивается каждые 3 с независимо от страницы и старого токена.
Страница только просит обновление и рисует итог.

Запрос к серверу Premium идёт обычным путём, а winws2 приостанавливается
только когда он мешает соединению — иначе автоматические проверки
дёргали бы обход.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:01:24 +03:00

681 lines
27 KiB
Python
Raw Permalink 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.

"""Проверкой Premium-статуса владеет один объект на всё время работы программы.
Главные сценарии, ради которых он появился:
- подписка не «пропадает» после перезапуска: за сохранённым статусом сразу
идёт проверка на сервере, а дальше она повторяется сама;
- код отправлен боту — привязка подхватывается без кнопки «Обновить статус»,
на какой бы странице ни был пользователь и есть ли на устройстве старый токен.
"""
from __future__ import annotations
import os
import sys
import tempfile
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
PROJECT_SRC = Path(__file__).resolve().parents[1] / "src"
if str(PROJECT_SRC) not in sys.path:
sys.path.insert(0, str(PROJECT_SRC))
from PyQt6.QtWidgets import QApplication # noqa: E402
from app.state_store import MainWindowStateStore # noqa: E402
from donater import status_schedule # noqa: E402
from donater.status_runtime import PremiumStatusRuntime # noqa: E402
from donater.subscription_ui import SubscriptionUiActions # noqa: E402
ACTIVE = {
"found": True,
"activated": True,
"is_premium": True,
"days_remaining": 40,
"status": "Активировано",
"subscription_level": "zapretik",
"source": "api",
"pairing_pending": False,
"network_failed": False,
"direct_window": False,
}
FREE_UNLINKED = {
"found": False,
"activated": False,
"is_premium": False,
"days_remaining": None,
"status": "Устройство не привязано",
"subscription_level": "–",
"source": "api",
"pairing_pending": False,
"network_failed": False,
"direct_window": False,
}
class _WorkerRuntime:
"""Подмена общего запуска фоновых задач: проверку завершает сам тест."""
def __init__(self) -> None:
self.request_id = 0
self.running = False
self.started: list[bool] = []
self.on_finished = None
self.stop = Mock(return_value=False)
self._worker = None
def is_running(self) -> bool:
return self.running
def is_current(self, request_id: int, *, cleanup_in_progress: bool = False) -> bool:
return (not cleanup_in_progress) and int(request_id) == int(self.request_id)
def start_qobject_worker(self, *, parent, worker_factory, bind_worker, on_finished):
_ = parent
self.request_id += 1
self._worker = worker_factory(self.request_id)
bind_worker(self._worker)
self.started.append(bool(self._worker._use_cache))
self.on_finished = on_finished
self.running = True
return self.request_id, self._worker, None
def cancel(self) -> None:
self.request_id += 1
self.running = False
class PremiumStatusScheduleTests(unittest.TestCase):
def test_pending_pair_code_is_polled_often(self) -> None:
info = dict(FREE_UNLINKED, pairing_pending=True)
self.assertEqual(
status_schedule.next_check_delay_ms(info),
status_schedule.PAIRING_POLL_MS,
)
self.assertLessEqual(status_schedule.PAIRING_POLL_MS, 5_000)
def test_pending_pair_code_is_polled_even_when_old_token_exists(self) -> None:
# Старый токен с неактивной подпиской раньше отключал опрос целиком.
info = dict(FREE_UNLINKED, found=True, pairing_pending=True)
self.assertEqual(
status_schedule.next_check_delay_ms(info),
status_schedule.PAIRING_POLL_MS,
)
def test_pairing_poll_slows_down_when_each_request_pauses_winws2(self) -> None:
info = dict(FREE_UNLINKED, pairing_pending=True, direct_window=True)
self.assertEqual(
status_schedule.next_check_delay_ms(info),
status_schedule.PAIRING_POLL_DIRECT_MS,
)
self.assertGreater(
status_schedule.PAIRING_POLL_DIRECT_MS,
status_schedule.PAIRING_POLL_MS,
)
def test_regular_refresh_is_far_inside_signed_cache_lifetime(self) -> None:
week_ms = 7 * 24 * 60 * 60 * 1000
self.assertEqual(status_schedule.next_check_delay_ms(ACTIVE), status_schedule.REFRESH_MS)
self.assertLess(status_schedule.REFRESH_MS * 10, week_ms)
def test_linked_inactive_device_is_rechecked_sooner(self) -> None:
info = dict(FREE_UNLINKED, found=True)
self.assertEqual(
status_schedule.next_check_delay_ms(info),
status_schedule.INACTIVE_REFRESH_MS,
)
self.assertLess(status_schedule.INACTIVE_REFRESH_MS, status_schedule.REFRESH_MS)
def test_network_failures_back_off(self) -> None:
info = dict(ACTIVE, network_failed=True, source="offline")
steps = status_schedule.NETWORK_RETRY_STEPS_MS
delays = [
status_schedule.next_check_delay_ms(info, network_failures=count)
for count in range(1, len(steps) + 3)
]
self.assertEqual(delays[: len(steps)], list(steps))
self.assertEqual(delays[-1], steps[-1])
self.assertEqual(delays, sorted(delays))
self.assertEqual(
status_schedule.next_check_delay_ms(None, network_failures=1),
steps[0],
)
def test_soft_refresh_is_rate_limited(self) -> None:
self.assertTrue(status_schedule.soft_refresh_allowed(now=10.0, last_network_check_at=0.0))
self.assertFalse(status_schedule.soft_refresh_allowed(now=100.0, last_network_check_at=90.0))
self.assertTrue(
status_schedule.soft_refresh_allowed(
now=100.0 + status_schedule.SOFT_REFRESH_MIN_GAP_SEC,
last_network_check_at=100.0,
)
)
class PremiumStatusRuntimeTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls._app = QApplication.instance() or QApplication([])
def setUp(self) -> None:
self.store = MainWindowStateStore()
self.set_status = Mock()
self.mark_startup_ready = Mock()
self.runtime = PremiumStatusRuntime(
thread_parent=None,
ui_actions=SubscriptionUiActions(
set_status=self.set_status,
ui_state_store=self.store,
mark_startup_ready=self.mark_startup_ready,
),
get_premium_checker=Mock(return_value="checker"),
check_device_activation=Mock(),
)
self.workers = _WorkerRuntime()
self.runtime._worker_runtime = self.workers
self.checked: list[dict] = []
self.failed: list[str] = []
self.runtime.status_checked.connect(self.checked.append)
self.runtime.status_failed.connect(self.failed.append)
def tearDown(self) -> None:
self.runtime.cleanup()
def _finish(self, payload, success: bool = True) -> None:
"""Завершает идущую проверку так, как это делает фоновый поток."""
request_id = self.workers.request_id
self.workers.running = False
self.runtime._on_check_finished(request_id, payload, success)
self.workers.on_finished(request_id, None)
# ── подписка не пропадает после перезапуска ──────────────────────────────
def test_startup_shows_saved_status_then_asks_server(self) -> None:
self.runtime.start()
self.assertEqual(self.workers.started, [True])
self._finish(dict(FREE_UNLINKED, source="cache"))
# Сохранённый статус показан, запуск не ждёт сеть...
self.mark_startup_ready.assert_called_once_with("subscription_ready")
self.assertTrue(self.store.snapshot().subscription_known)
# ...и сразу следом назначена проверка на сервере.
self.assertTrue(self.runtime._timer.isActive())
self.assertEqual(
self.runtime._timer.interval(),
status_schedule.STARTUP_NETWORK_DELAY_MS,
)
self.runtime._on_timer()
self.assertEqual(self.workers.started, [True, False])
def test_expired_saved_status_is_restored_by_server_without_button(self) -> None:
# Ровно жалоба: сохранённый ответ устарел, после перезапуска «Free».
self.runtime.start()
self._finish(dict(FREE_UNLINKED, found=True, source="cache", status="Не активировано"))
self.assertFalse(self.store.snapshot().subscription_is_premium)
self.runtime._on_timer()
self._finish(ACTIVE)
self.assertTrue(self.store.snapshot().subscription_is_premium)
self.assertEqual(self.store.snapshot().subscription_days_remaining, 40)
self.assertEqual(self.runtime._timer.interval(), status_schedule.REFRESH_MS)
self.assertTrue(self.runtime._timer.isActive())
def test_refresh_repeats_forever_without_any_page(self) -> None:
self.runtime.start()
self._finish(dict(ACTIVE, source="cache"))
for _round in range(3):
self.assertTrue(self.runtime._timer.isActive())
self.runtime._on_timer()
self.assertFalse(self.workers.started[-1])
self._finish(ACTIVE)
self.assertEqual(self.workers.started, [True, False, False, False])
def test_startup_failure_keeps_status_unknown_and_retries(self) -> None:
self.runtime.start()
self._finish("база недоступна", success=False)
self.mark_startup_ready.assert_called_once_with("subscription_init_failed")
self.assertFalse(self.store.snapshot().subscription_known)
self.assertEqual(self.failed, ["база недоступна"])
self.assertTrue(self.runtime._timer.isActive())
def test_broken_check_does_not_turn_premium_into_free(self) -> None:
self.runtime.start()
self._finish(dict(ACTIVE, source="cache"))
self.runtime._on_timer()
self._finish("сбой", success=False)
self.assertTrue(self.store.snapshot().subscription_is_premium)
self.assertEqual(
self.runtime._timer.interval(),
status_schedule.NETWORK_RETRY_STEPS_MS[0],
)
def test_network_failure_retries_with_backoff_and_recovers(self) -> None:
offline = dict(ACTIVE, network_failed=True, source="offline")
self.runtime.start()
self._finish(dict(ACTIVE, source="cache"))
seen = []
for _attempt in range(3):
self.runtime._on_timer()
self._finish(offline)
seen.append(self.runtime._timer.interval())
self.assertEqual(seen, list(status_schedule.NETWORK_RETRY_STEPS_MS[:3]))
self.runtime._on_timer()
self._finish(ACTIVE)
self.assertEqual(self.runtime._timer.interval(), status_schedule.REFRESH_MS)
self.runtime._on_timer()
self._finish(offline)
self.assertEqual(
self.runtime._timer.interval(),
status_schedule.NETWORK_RETRY_STEPS_MS[0],
)
# ── код подхватывается без кнопки ────────────────────────────────────────
def test_created_pair_code_is_polled_until_bot_confirms(self) -> None:
waiting = dict(FREE_UNLINKED, pairing_pending=True, status="Ожидание привязки")
self.runtime.start()
self._finish(dict(FREE_UNLINKED, source="cache"))
# Страница сообщила: код создан.
self.runtime.request_refresh(force=True)
self.assertEqual(self.workers.started[-1], False)
for _poll in range(3):
self._finish(waiting)
self.assertEqual(self.runtime._timer.interval(), status_schedule.PAIRING_POLL_MS)
self.assertTrue(self.runtime._timer.isActive())
self.runtime._on_timer()
self._finish(ACTIVE)
self.assertTrue(self.store.snapshot().subscription_is_premium)
self.assertEqual(self.checked[-1], ACTIVE)
self.assertEqual(self.runtime._timer.interval(), status_schedule.REFRESH_MS)
def test_pending_pair_code_found_at_startup_resumes_polling(self) -> None:
# Программу перезапустили, пока код ждал подтверждения.
self.runtime.start()
self._finish(dict(FREE_UNLINKED, pairing_pending=True, source="cache"))
self.runtime._on_timer()
self._finish(dict(FREE_UNLINKED, pairing_pending=True))
self.assertEqual(self.runtime._timer.interval(), status_schedule.PAIRING_POLL_MS)
def test_forced_refresh_during_running_check_runs_again_after_it(self) -> None:
self.runtime.start()
self.runtime.request_refresh(force=True)
self.assertEqual(self.workers.started, [True])
with patch("donater.status_runtime.QTimer.singleShot") as single_shot:
self._finish(dict(FREE_UNLINKED, source="cache"))
single_shot.assert_called_once()
self.assertEqual(single_shot.call_args.args[0], 0)
single_shot.call_args.args[1]()
self.assertEqual(self.workers.started, [True, False])
self.assertFalse(self.runtime._timer.isActive())
def test_soft_refresh_is_dropped_right_after_network_check(self) -> None:
self.runtime.start()
self._finish(dict(ACTIVE, source="cache"))
self.runtime._on_timer()
self._finish(ACTIVE)
self.runtime.request_refresh()
self.assertEqual(self.workers.started, [True, False])
self.runtime._last_network_check_at = (
time.monotonic() - status_schedule.SOFT_REFRESH_MIN_GAP_SEC - 1
)
self.runtime.request_refresh()
self.assertEqual(self.workers.started, [True, False, False])
# ── запись статуса и сброс ───────────────────────────────────────────────
def test_store_is_written_only_when_status_changes(self) -> None:
seen: list[frozenset[str]] = []
self.store.subscribe(
lambda _state, changed: seen.append(changed),
fields={"subscription_known", "subscription_is_premium", "subscription_days_remaining"},
)
with patch("donater.status_runtime.apply_premium_state_to_store") as write:
self.runtime.start()
self._finish(dict(ACTIVE, source="cache"))
for _round in range(3):
self.runtime._on_timer()
self._finish(ACTIVE)
self.assertEqual(write.call_count, 1)
self.runtime._on_timer()
self._finish(dict(ACTIVE, days_remaining=39))
self.assertEqual(write.call_count, 2)
# Страница получает итог каждой проверки, даже если статус тот же.
self.assertEqual(len(self.checked), 5)
def test_local_reset_makes_device_free_and_drops_stale_result(self) -> None:
self.runtime.start()
self._finish(dict(ACTIVE, source="cache"))
self.runtime._on_timer()
# Проверка ещё идёт, а пользователь уже сбросил привязку.
self.runtime.apply_local_reset()
self.assertFalse(self.store.snapshot().subscription_is_premium)
self._finish(ACTIVE)
self.assertFalse(self.store.snapshot().subscription_is_premium)
self.assertEqual(len(self.checked), 1)
self.assertTrue(self.runtime._timer.isActive())
def test_cleanup_stops_schedule_and_ignores_late_result(self) -> None:
self.runtime.start()
request_id = self.workers.request_id
self.runtime.cleanup()
self.runtime._on_check_finished(request_id, ACTIVE, True)
self.assertFalse(self.runtime._timer.isActive())
self.assertFalse(self.store.snapshot().subscription_known)
self.workers.stop.assert_called_once()
self.assertFalse(self.workers.stop.call_args.kwargs["blocking"])
self.runtime.request_refresh(force=True)
self.assertEqual(self.workers.started, [True])
class _Api:
uses_direct_window = False
def __init__(self) -> None:
self.confirmed = False
self.finish_calls = 0
self.check_calls = 0
self.activated = True
@staticmethod
def _response(payload, nonce):
return {"success": True, "signed": payload, "kid": "test", "sig": "test", "_http_status": 200}, nonce
def post_pair_start(self, *, request_id, device_id, device_name=None):
_ = device_name
return self._response(
{
"type": "zapret_pairing_started",
"request_id": request_id,
"device_id": device_id,
"pairing_id": "pairing-1",
"pair_code": "ABCDEFGH",
"pair_expires_at": int(time.time()) + 600,
},
"start-nonce",
)
def post_pair_finish(self, *, request_id, device_id, pairing_id):
_ = request_id, pairing_id
self.finish_calls += 1
if not self.confirmed:
return (
{
"success": False,
"error": {"code": "pairing_not_confirmed", "retryable": True},
"_http_status": 409,
},
"finish-nonce",
)
return self._response(
{
"type": "zapret_premium_activation",
"device_id": device_id,
"binding_id": "binding-2",
"binding_generation": 2,
"device_token": "fresh-secret-token",
"linked": True,
"activated": True,
"subscription_level": "zapretik",
"expires_at": "2099-01-01T00:00:00+00:00",
"expires_at_epoch": 4070908800,
"days_remaining": 100,
"valid_until": int(time.time()) + 3600,
"message": "Активировано",
},
"finish-nonce",
)
def post_check(self, *, request_id, device_id, device_token):
_ = request_id
self.check_calls += 1
activated = self.activated and device_token == "fresh-secret-token"
return self._response(
{
"type": "zapret_premium_status",
"device_id": device_id,
"linked": True,
"activated": activated,
"subscription_level": "zapretik" if activated else None,
"expires_at": "2099-01-01T00:00:00+00:00" if activated else None,
"expires_at_epoch": 4070908800 if activated else None,
"days_remaining": 100 if activated else 0,
"valid_until": int(time.time()) + 3600,
"message": "Активировано" if activated else "Подписка не активна",
},
"status-nonce",
)
class PremiumPairingStatusFlagsTests(unittest.TestCase):
"""Сервис сообщает владельцу статуса, ждать ли ещё подтверждения кода."""
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
from settings import store
self.store = store
self.old_root = store.MAIN_DIRECTORY
store.close_settings_database()
store.MAIN_DIRECTORY = self.tmp.name
def tearDown(self) -> None:
self.store.close_settings_database()
self.store.MAIN_DIRECTORY = self.old_root
self.tmp.cleanup()
@staticmethod
def _verify(raw, *, expected_device_id, expected_nonce=None, **_kwargs):
_ = expected_nonce
signed = raw.get("signed") if isinstance(raw, dict) else None
if not isinstance(signed, dict):
return None
if str(signed.get("device_id")) != str(expected_device_id):
return None
return signed
def _service(self):
from donater.service import PremiumService
service = PremiumService(api_base_url="https://premium.test/api")
service._api = _Api()
return service
def test_pair_code_waits_then_binds_without_manual_refresh(self) -> None:
service = self._service()
with patch("donater.service.verify_signed_response", side_effect=self._verify):
self.assertTrue(service.pair_start()[0])
waiting = service.check_device_activation()
self.assertTrue(waiting["pairing_pending"])
self.assertFalse(waiting["activated"])
self.assertFalse(waiting["network_failed"])
# Тот же ответ даёт и проход без сети — так опрос возобновляется
# после перезапуска программы.
self.assertTrue(service.check_device_activation(use_cache=True)["pairing_pending"])
service._api.confirmed = True
bound = service.check_device_activation()
self.assertTrue(bound["activated"])
self.assertTrue(bound["found"])
self.assertFalse(bound["pairing_pending"])
def test_new_pair_code_is_finished_even_with_old_inactive_token(self) -> None:
# Случай со снимка: токен устройства есть, подписка «не активна»,
# пользователь создаёт новый код и отправляет его боту.
from donater.storage import PremiumStorage
service = self._service()
device_id = PremiumStorage.get_device_id()
with patch("donater.service.verify_signed_response", side_effect=self._verify):
self.assertTrue(
PremiumStorage.store_after_pairing(
device_id=device_id,
binding_id="binding-1",
binding_generation=1,
device_token="old-secret-token",
signed_payload={"device_id": device_id, "activated": False},
kid="test",
sig="test",
)
)
self.assertTrue(service.pair_start()[0])
waiting = service.check_device_activation()
self.assertTrue(waiting["found"])
self.assertFalse(waiting["activated"])
self.assertTrue(waiting["pairing_pending"])
self.assertEqual(service._api.finish_calls, 1)
service._api.confirmed = True
bound = service.check_device_activation()
self.assertTrue(bound["activated"])
self.assertFalse(bound["pairing_pending"])
self.assertEqual(PremiumStorage.get_binding()["binding_id"], "binding-2")
def test_expired_pair_code_stops_waiting(self) -> None:
from donater.storage import PremiumStorage
service = self._service()
with patch("donater.service.verify_signed_response", side_effect=self._verify):
self.assertTrue(service.pair_start()[0])
with patch("donater.service.time.time", return_value=time.time() + 3600):
expired = service.check_device_activation()
self.assertFalse(expired["pairing_pending"])
self.assertIsNone(PremiumStorage.get_pending_pairing())
self.assertEqual(service._api.finish_calls, 0)
def test_network_failure_is_reported_for_backoff(self) -> None:
service = self._service()
service._api.post_pair_finish = Mock(
return_value=(
{
"success": False,
"error": {"code": "connect_timeout", "retryable": True},
"_http_status": 0,
},
"finish-nonce",
)
)
with patch("donater.service.verify_signed_response", side_effect=self._verify):
self.assertTrue(service.pair_start()[0])
result = service.check_device_activation()
self.assertTrue(result["network_failed"])
self.assertTrue(result["pairing_pending"])
self.assertEqual(
status_schedule.next_check_delay_ms(result),
status_schedule.PAIRING_NETWORK_RETRY_MS,
)
class PremiumPageWaitingForBotTests(unittest.TestCase):
"""Пока код ждёт подтверждения, страница не прячет и не стирает его."""
@classmethod
def setUpClass(cls) -> None:
cls._app = QApplication.instance() or QApplication([])
def _apply(self, result, *, code: str):
from PyQt6.QtWidgets import QLineEdit
from donater.ui.status_workflow import apply_status_check_success
key_input = QLineEdit()
key_input.setText(code)
calls = SimpleNamespace(
activation_status=Mock(),
section_visible=Mock(),
)
apply_status_check_success(
result,
tr=lambda _key, default, **kwargs: default.format(**kwargs) if kwargs else default,
refresh_btn=SimpleNamespace(set_loading=lambda _value: None),
key_input=key_input,
update_device_info=lambda: None,
set_status_badge=lambda **_kwargs: None,
set_activation_status=calls.activation_status,
set_activation_section_visible=calls.section_visible,
)
return key_input, calls
def test_waiting_result_keeps_code_visible_for_linked_device(self) -> None:
key_input, calls = self._apply(
dict(FREE_UNLINKED, found=True, pairing_pending=True),
code="ABCD12EF",
)
self.assertEqual(key_input.text(), "ABCD12EF")
calls.section_visible.assert_called_once_with(True)
calls.activation_status.assert_not_called()
def test_confirmed_code_is_cleared_and_section_hidden(self) -> None:
key_input, calls = self._apply(ACTIVE, code="ABCD12EF")
self.assertEqual(key_input.text(), "")
calls.section_visible.assert_called_once_with(False)
self.assertEqual(
calls.activation_status.call_args.kwargs["text_key"],
"page.premium.activation.success.linked_active",
)
def test_dead_code_is_cleared_for_unlinked_device(self) -> None:
key_input, calls = self._apply(
dict(FREE_UNLINKED, status="Код истёк. Создайте новый код."),
code="ABCD12EF",
)
self.assertEqual(key_input.text(), "")
calls.section_visible.assert_called_once_with(True)
calls.activation_status.assert_called_once_with(text="")
if __name__ == "__main__":
unittest.main()