zapretgui/tests/test_updater_release_watch.py
loop-uh 174fbd70e2
All checks were successful
Zapret source guards / validate (push) Successful in 10s
Windows-сборка / Dev Dev 21.1.7.194 опубликован
Ссылки на git и вики: открытое имя сайта выбирается один раз и для обновления, и для кнопок
Основные имена git.zapret.moe и wiki.zapret.moe режут по TLS. Теперь общий модуль
utils/site_links (на ядре zapret_update/mirrors, семейства git и wiki) выбирает первое
ответившее имя и запоминает его; им пользуются проверка выпуска, очередь обновлений,
хранилище файлов и все кнопки со ссылками (Справка, Исходный код, Документация, заявки,
тур). Адреса пишутся один раз в config/urls.py на основных именах.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 06:17:57 -04:00

429 lines
18 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.

from __future__ import annotations
"""Слушатель сигнала сервера о новой версии («длинный запрос»)."""
import unittest
from unittest.mock import patch
from updater.release import watch
from updater.release.watch import ReleaseWatcher, WaitEndpoint
FORGEJO = WaitEndpoint("Forgejo", "https://git.example/api/zapret/wait", True)
MIRROR = WaitEndpoint("Зеркало", "https://192.0.2.1:888/api/wait", False)
class _Response:
def __init__(self, payload, status: int = 200) -> None:
self._payload = payload
self.status_code = status
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise OSError(f"HTTP {self.status_code}")
def json(self):
return self._payload
class _Harness:
"""Сервер и часы слушателя: ответы идут по сценарию, паузы не ждут."""
def __init__(self, test: unittest.TestCase, script: list, *, enabled=lambda: True, **watcher_options) -> None:
self.script = list(script)
self.requests: list[tuple[str, bool]] = []
self.pauses: list[float] = []
self.released: list[str] = []
self.queued: list[str] = []
self.now = 0.0
self.watcher = ReleaseWatcher(
channel="dev",
current_version="21.1.7.118",
on_release=self.released.append,
on_queued=self.queued.append,
is_enabled=enabled,
endpoints=lambda: (FORGEJO, MIRROR),
session_factory=lambda: self,
clock=lambda: self.now,
**watcher_options,
)
patcher = patch.object(self.watcher, "_pause", side_effect=self._pause)
patcher.start()
test.addCleanup(patcher.stop)
log_patcher = patch.object(watch, "log")
log_patcher.start()
test.addCleanup(log_patcher.stop)
# Сессия requests.
def get(self, url: str, *, timeout, verify, headers):
self.requests.append((url, verify))
if not self.script:
self.watcher.stop()
raise OSError("сценарий кончился")
step = self.script.pop(0)
probe = "hold=0" in url
took, answer = step if isinstance(step, tuple) else (0.2 if probe else 300.0, step)
self.now += took
if isinstance(answer, BaseException):
raise answer
return answer
def close(self) -> None:
pass
def _pause(self, seconds: float) -> bool:
self.pauses.append(float(seconds))
return self.watcher._stop.is_set()
def run(self) -> None:
self.watcher.run()
def _quiet(version: str = "21.1.7.118") -> _Response:
return _Response({"channel": "dev", "version": version, "changed": False})
def _news(version: str) -> _Response:
return _Response({"channel": "dev", "version": version, "changed": True})
def _queued(version: str, ticket: int = 0) -> _Response:
payload = {"channel": "dev", "version": version, "changed": False, "queued": True}
if ticket:
payload["ticket"] = ticket
return _Response(payload)
def _held(version: str, reason: str) -> _Response:
return _Response({"channel": "dev", "version": version, "changed": False, "held": reason})
class ReleaseWatcherTests(unittest.TestCase):
def test_answer_wait_for_your_stage_is_not_a_permission(self) -> None:
harness = _Harness(
self, [_held("21.1.7.119", "stage"), _held("21.1.7.119", "stage"), _held("21.1.7.119", "halted"), _news("21.1.7.119"), _quiet("21.1.7.119")]
)
harness.run()
# Сервер раздаёт версию по ступеням: пока очередь не подошла, программа
# ничего не ставит и никого не тревожит.
self.assertEqual(harness.queued, [])
self.assertEqual(harness.released, ["21.1.7.119"])
self.assertIn("known=21.1.7.118", harness.requests[3][0])
# Долгий вопрос честно отстоял свой срок: лишней паузы нет.
self.assertNotIn(watch.QUICK_ANSWER_PAUSE_SECONDS, harness.pauses)
def test_busy_program_says_so_and_asks_shorter(self) -> None:
busy = [True, True, True, False]
harness = _Harness(
self, [_quiet(), _quiet(), _quiet(), _quiet()], is_busy=lambda: busy.pop(0) if busy else False
)
harness.run()
urls = [url for url, _verify in harness.requests]
self.assertIn("busy=1", urls[0])
self.assertIn("hold=0", urls[0])
# Занятая программа переспрашивает раз в минуту: освободится — узнает быстро.
self.assertIn("busy=1", urls[1])
self.assertIn("busy=1", urls[2])
self.assertIn(f"hold={watch.BUSY_HOLD_SECONDS}", urls[2])
self.assertNotIn("busy=", urls[3])
self.assertNotIn("hold=", urls[3])
def test_update_outcome_is_reported_until_the_server_hears_it(self) -> None:
pending = [{"prev": "21.1.7.117", "took": "24"}]
delivered: list[dict] = []
def done(report: dict) -> None:
delivered.append(report)
pending.clear()
harness = _Harness(
self,
[OSError("нет сети"), _quiet(), _quiet()],
pending_report=lambda: dict(pending[0]) if pending else {},
report_delivered=done,
)
harness.run()
urls = [url for url, _verify in harness.requests]
# Первый вопрос не дошёл: сообщение повторяется на следующем источнике.
self.assertIn("prev=21.1.7.117", urls[0])
self.assertIn("prev=21.1.7.117", urls[1])
self.assertIn("took=24", urls[1])
self.assertEqual(delivered, [{"prev": "21.1.7.117", "took": "24"}])
self.assertNotIn("prev=", urls[2])
def test_program_tells_what_it_is_doing_and_nothing_else(self) -> None:
harness = _Harness(
self,
[_quiet(), _quiet()],
activity=lambda: {"act": "tray", "run": "1", "user": "вася", "path": "C:/secret"},
)
harness.run()
for url, _verify in harness.requests:
self.assertIn("act=tray", url)
self.assertIn("run=1", url)
# В вопрос уходят только занятие и «обход включён» — ничего лишнего.
self.assertNotIn("user", url)
self.assertNotIn("secret", url)
def test_unknown_activity_is_simply_not_sent(self) -> None:
harness = _Harness(self, [_quiet()], activity=lambda: {"act": "", "run": None})
harness.run()
self.assertNotIn("act=", harness.requests[0][0])
self.assertNotIn("run=", harness.requests[0][0])
def test_broken_helpers_do_not_stop_the_listener(self) -> None:
def broken():
raise RuntimeError("сбой")
harness = _Harness(
self, [_quiet(), _news("21.1.7.119")], is_busy=broken, pending_report=broken, activity=broken
)
harness.run()
self.assertEqual(harness.released, ["21.1.7.119"])
self.assertNotIn("busy=", harness.requests[0][0])
def test_first_question_is_a_probe_then_questions_wait(self) -> None:
harness = _Harness(self, [_quiet(), _quiet(), _quiet()])
harness.run()
urls = [url for url, _verify in harness.requests]
# Проба сразу показывает, что служба на месте; дальше — долгие вопросы.
self.assertIn("hold=0", urls[0])
# Сразу после запуска состояние программы быстро меняется: первый
# долгий вопрос короче обычного, дальше — в полный срок.
self.assertIn(f"hold={watch.WARMUP_HOLD_SECONDS}", urls[1])
self.assertNotIn("hold=", urls[2])
self.assertIn("known=21.1.7.118", urls[0])
self.assertIn("channel=dev", urls[0])
def test_permission_is_reported_once_and_version_becomes_known(self) -> None:
harness = _Harness(self, [_quiet(), _news("21.1.7.119"), _quiet("21.1.7.119")])
harness.run()
self.assertEqual(harness.released, ["21.1.7.119"])
# Следующий вопрос — уже про новую версию: о ней разрешение больше не придёт.
self.assertIn("known=21.1.7.119", harness.requests[2][0])
def test_revoked_installed_version_is_replaced_urgently(self) -> None:
"""Выключатель: сервер отозвал установленную версию — срочный вызов, не обычный."""
revoked: list[str] = []
answer = _Response({"channel": "dev", "version": "21.1.7.119", "changed": True, "revoked": True})
harness = _Harness(self, [_quiet(), answer, _quiet("21.1.7.119")], on_revoked=revoked.append)
harness.run()
self.assertEqual(revoked, ["21.1.7.119"])
self.assertEqual(harness.released, [])
def test_permission_may_come_right_in_the_probe(self) -> None:
# Программу запустили, когда её очередь уже прошла.
harness = _Harness(self, [_news("21.1.7.119")])
harness.run()
self.assertEqual(harness.released, ["21.1.7.119"])
def test_queued_version_is_reported_once_and_is_not_a_permission(self) -> None:
harness = _Harness(self, [_queued("21.1.7.119"), _queued("21.1.7.119"), _news("21.1.7.119")])
harness.run()
self.assertEqual(harness.queued, ["21.1.7.119"])
self.assertEqual(harness.released, ["21.1.7.119"])
self.assertEqual(harness.watcher.queued_version, "")
# В очереди программа по-прежнему спрашивает про свою версию.
self.assertIn("known=21.1.7.118", harness.requests[2][0])
def test_queue_ticket_is_kept_and_named_in_next_questions(self) -> None:
harness = _Harness(
self,
[_queued("21.1.7.119", ticket=1791570000), _queued("21.1.7.119", ticket=1791579999), _news("21.1.7.119"), _quiet("21.1.7.119")],
)
harness.run()
urls = [url for url, _verify in harness.requests]
self.assertNotIn("ticket=", urls[0])
# Талон — время первой постановки: программа держится за него и не
# берёт новый, который сервер назвал позже.
self.assertIn("ticket=1791570000", urls[1])
self.assertIn("ticket=1791570000", urls[2])
# Разрешение получено: талон больше не нужен.
self.assertNotIn("ticket=", urls[3])
def test_ticket_of_one_version_is_not_used_for_the_next(self) -> None:
harness = _Harness(
self,
[_queued("21.1.7.119", ticket=1791570000), _queued("21.1.7.120", ticket=1791571111), _quiet()],
)
harness.run()
self.assertIn("ticket=1791571111", harness.requests[2][0])
self.assertEqual(harness.queued, ["21.1.7.119", "21.1.7.120"])
def test_garbage_ticket_from_server_is_ignored(self) -> None:
harness = _Harness(self, [_Response({"version": "21.1.7.119", "changed": False, "queued": True, "ticket": "мусор"}), _quiet()])
harness.run()
self.assertNotIn("ticket=", harness.requests[1][0])
def test_watcher_tells_whether_the_server_queue_is_reachable(self) -> None:
harness = _Harness(self, [_quiet()])
self.assertIsNone(harness.watcher.reachable)
harness.run()
self.assertIs(harness.watcher.reachable, True)
self.assertIs(harness.watcher.wait_until_probed(0), True)
def test_reachable_after_the_probe(self) -> None:
seen: list = []
harness = _Harness(self, [_quiet(), _quiet()])
original = harness.get
def get(url, **kwargs):
seen.append(harness.watcher.reachable)
return original(url, **kwargs)
harness.get = get
harness.run()
self.assertEqual(seen[:2], [None, True])
def test_quiet_answer_after_full_hold_is_asked_again_without_pause(self) -> None:
harness = _Harness(self, [_quiet(), _quiet(), _quiet()])
harness.run()
self.assertEqual(harness.released, [])
self.assertNotIn(watch.QUICK_ANSWER_PAUSE_SECONDS, harness.pauses)
def test_older_or_same_version_is_not_news(self) -> None:
# Отставшее зеркало: «можно» с версией не новее известной.
harness = _Harness(self, [_quiet(), _news("21.1.7.118"), _news("21.1.7.90")])
harness.run()
self.assertEqual(harness.released, [])
def test_broken_answers_do_not_turn_into_a_request_storm(self) -> None:
# Чужой прокси или старый сервер отвечает мгновенно и без разрешения.
harness = _Harness(
self,
[_quiet(), (0.1, _quiet()), (0.1, _news("чепуха")), (0.1, _queued("21.1.7.119")), (0.1, _Response({}))],
)
harness.run()
self.assertEqual(harness.released, [])
self.assertEqual(harness.pauses[:4], [watch.QUICK_ANSWER_PAUSE_SECONDS] * 4)
def test_failed_source_hands_over_to_the_next_one_and_returns(self) -> None:
harness = _Harness(
self,
[OSError("Forgejo недоступен"), _quiet(), _news("21.1.7.119"), _quiet("21.1.7.119")],
)
harness.run()
urls = [url for url, _verify in harness.requests]
self.assertEqual(harness.released, ["21.1.7.119"])
self.assertTrue(urls[0].startswith(FORGEJO.url))
# На зеркале — проба и один полный вопрос с ожиданием…
self.assertTrue(urls[1].startswith(MIRROR.url) and "hold=0" in urls[1])
self.assertTrue(urls[2].startswith(MIRROR.url) and "hold=0" not in urls[2])
# …после чего слушатель снова пробует Forgejo.
self.assertTrue(urls[3].startswith(FORGEJO.url) and "hold=0" in urls[3])
# Сертификат зеркала самоподписанный, у Forgejo — проверяется.
self.assertEqual([verify for _url, verify in harness.requests[:2]], [True, False])
self.assertEqual(harness.pauses[0], watch.NEXT_SOURCE_PAUSE_SECONDS)
def test_pauses_grow_when_nobody_answers(self) -> None:
harness = _Harness(self, [OSError("нет сети")] * 6)
harness.run()
rounds = [pause for pause in harness.pauses if pause != watch.NEXT_SOURCE_PAUSE_SECONDS]
# Паузы растут, но не дольше трёх минут: вернулась сеть — программа узнаёт быстро.
self.assertEqual(rounds[:3], [60.0, 120.0, 180.0])
self.assertIs(harness.watcher.reachable, False)
def test_http_error_counts_as_failure(self) -> None:
# На сервере ещё нет службы ожидания: 404.
harness = _Harness(self, [_Response({}, status=404), _news("21.1.7.119")])
harness.run()
self.assertEqual(harness.released, ["21.1.7.119"])
self.assertTrue(harness.requests[1][0].startswith(MIRROR.url))
def test_disabled_auto_update_holds_no_connection(self) -> None:
states = iter([False, False, True])
harness = _Harness(self, [_news("21.1.7.119")], enabled=lambda: next(states, True))
harness.run()
self.assertEqual(harness.pauses[:2], [watch.DISABLED_RECHECK_SECONDS] * 2)
self.assertEqual(harness.released, ["21.1.7.119"])
def test_failing_listener_of_the_signal_does_not_stop_the_watcher(self) -> None:
harness = _Harness(self, [_news("21.1.7.119"), _news("21.1.7.120")])
seen: list[str] = []
def on_release(version: str) -> None:
seen.append(version)
raise RuntimeError("окно уже закрыто")
harness.watcher._on_release = on_release
harness.run()
self.assertEqual(seen, ["21.1.7.119", "21.1.7.120"])
class WaitEndpointsTests(unittest.TestCase):
def test_forgejo_queue_uses_the_open_name_of_the_server(self) -> None:
with patch("updater.release.mirrors.mirror_servers", return_value=[]), patch(
"utils.site_links.chosen_host", return_value="git2.zapret.moe"
):
endpoints = watch.wait_endpoints()
self.assertEqual(endpoints[0].url, "https://git2.zapret.moe/api/zapret/wait")
def test_forgejo_goes_first_then_mirrors(self) -> None:
servers = [
{"name": "Первое", "host": "192.0.2.1", "https_port": 888, "http_port": 887},
{"name": "Второе", "host": "192.0.2.2", "https_port": 888, "http_port": 887},
]
with patch("updater.release.mirrors.mirror_servers", return_value=servers), patch(
"utils.site_links.chosen_host", return_value="git.zapret.moe"
):
endpoints = watch.wait_endpoints()
self.assertEqual(
[(item.name, item.url, item.verify_ssl) for item in endpoints],
[
("Forgejo", "https://git2.zapret.moe/api/zapret/wait", True),
("Первое", "https://192.0.2.1:888/api/wait", False),
("Второе", "https://192.0.2.2:888/api/wait", False),
],
)
if __name__ == "__main__":
unittest.main()