zapretgui/tests/test_remote_preset_sync.py

363 lines
15 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 Mock, patch
import requests
from presets.remote_sync import (
AUTO_CHECK_INTERVAL_SECONDS,
MAX_REMOTE_PRESET_BYTES,
STATUS_DETACHED,
STATUS_ERROR,
STATUS_NOT_MODIFIED,
STATUS_SKIPPED,
STATUS_UNCHANGED,
STATUS_UPDATED,
RemoteFetchResult,
RemoteSyncFetchError,
comparison_hash,
fetch_remote_preset_text,
should_auto_check,
sync_remote_preset,
upstream_matches_local,
)
NOW_ISO = "2026-08-14T12:00:00Z"
VALID_WINWS2_TEXT = (
"# Preset: Example\n"
"--wf-tcp-out=80,443\n"
"--lua-desync=hostfakesplit:host=x.com:tcp_ts=-1000\n"
)
UPDATED_WINWS2_TEXT = (
"# Preset: Example\n"
"--wf-tcp-out=80,443,8080\n"
"--lua-desync=hostfakesplit:host=y.com:tcp_ts=-500\n"
)
def _binding(**overrides):
base = {
"url": "https://example.com/p.txt",
"etag": "",
"last_modified": "",
"synced_hash": comparison_hash(VALID_WINWS2_TEXT),
"checked_at": "",
"updated_at": "",
"error": "",
"auto": True,
"detached": False,
}
base.update(overrides)
return base
def _sync(binding, *, current_text=VALID_WINWS2_TEXT, fetch=None, save=None, force=False):
save = save or Mock(return_value=None)
outcome = sync_remote_preset(
binding,
engine="winws2",
read_current_text=lambda: current_text,
fetch=fetch or Mock(return_value=RemoteFetchResult(status_code=304)),
save_text=save,
now_iso=NOW_ISO,
force=force,
)
return outcome, save
class ComparisonHashTests(unittest.TestCase):
def test_ignores_comments_and_debug_line(self):
base = comparison_hash(VALID_WINWS2_TEXT)
renamed = VALID_WINWS2_TEXT.replace("# Preset: Example", "# Preset: Renamed")
with_debug = VALID_WINWS2_TEXT + "--debug=@C:\\logs\\preset.log\n"
crlf = VALID_WINWS2_TEXT.replace("\n", "\r\n")
self.assertEqual(comparison_hash(renamed), base)
self.assertEqual(comparison_hash(with_debug), base)
self.assertEqual(comparison_hash(crlf), base)
def test_detects_meaningful_change(self):
self.assertNotEqual(
comparison_hash(VALID_WINWS2_TEXT), comparison_hash(UPDATED_WINWS2_TEXT)
)
class ShouldAutoCheckTests(unittest.TestCase):
def _parse(self, value):
return {"old": 0.0, "fresh": 1000.0}.get(value)
def test_never_checked_is_due(self):
self.assertTrue(should_auto_check(_binding(), now_ts=1.0, parse_ts=self._parse))
def test_interval_gate(self):
binding = _binding(checked_at="old")
self.assertTrue(
should_auto_check(binding, now_ts=AUTO_CHECK_INTERVAL_SECONDS + 1, parse_ts=self._parse)
)
binding = _binding(checked_at="fresh")
self.assertFalse(should_auto_check(binding, now_ts=1500.0, parse_ts=self._parse))
def test_disabled_or_detached(self):
self.assertFalse(should_auto_check(_binding(auto=False), now_ts=1e12, parse_ts=self._parse))
self.assertFalse(should_auto_check(_binding(detached=True), now_ts=1e12, parse_ts=self._parse))
def test_unparsable_checked_at_is_due(self):
self.assertTrue(
should_auto_check(_binding(checked_at="garbage"), now_ts=1.0, parse_ts=self._parse)
)
class SyncRemotePresetTests(unittest.TestCase):
def test_not_modified_updates_checked_at_only(self):
binding = _binding(etag='W/"1"')
fetch = Mock(return_value=RemoteFetchResult(status_code=304))
outcome, save = _sync(binding, fetch=fetch)
self.assertEqual(outcome.status, STATUS_NOT_MODIFIED)
self.assertEqual(outcome.binding_updates["checked_at"], NOW_ISO)
save.assert_not_called()
fetch.assert_called_once_with("https://example.com/p.txt", 'W/"1"', "")
def test_unchanged_content_does_not_save(self):
fetch = Mock(
return_value=RemoteFetchResult(status_code=200, text=VALID_WINWS2_TEXT, etag='W/"2"')
)
outcome, save = _sync(_binding(), fetch=fetch)
self.assertEqual(outcome.status, STATUS_UNCHANGED)
self.assertEqual(outcome.binding_updates["etag"], 'W/"2"')
save.assert_not_called()
def test_updated_content_saves_via_pipeline(self):
fetch = Mock(return_value=RemoteFetchResult(status_code=200, text=UPDATED_WINWS2_TEXT))
outcome, save = _sync(_binding(), fetch=fetch)
self.assertEqual(outcome.status, STATUS_UPDATED)
save.assert_called_once_with(UPDATED_WINWS2_TEXT)
self.assertEqual(outcome.binding_updates["updated_at"], NOW_ISO)
self.assertEqual(
outcome.binding_updates["synced_hash"], comparison_hash(UPDATED_WINWS2_TEXT)
)
self.assertFalse(outcome.binding_updates["detached"])
def test_synced_hash_uses_saved_normalized_text(self):
normalized = UPDATED_WINWS2_TEXT + "# normalized-marker\n"
fetch = Mock(return_value=RemoteFetchResult(status_code=200, text=UPDATED_WINWS2_TEXT))
save = Mock(return_value=normalized)
outcome, _ = _sync(_binding(), fetch=fetch, save=save)
self.assertEqual(outcome.binding_updates["synced_hash"], comparison_hash(normalized))
def test_invalid_remote_text_is_error_without_save(self):
fetch = Mock(return_value=RemoteFetchResult(status_code=200, text="случайный текст\n"))
outcome, save = _sync(_binding(), fetch=fetch)
self.assertEqual(outcome.status, STATUS_ERROR)
self.assertIn("не похож на пресет", outcome.binding_updates["error"])
save.assert_not_called()
def test_winws2_requires_lua_desync(self):
no_lua = "# Preset: X\n--wf-tcp-out=443\n"
fetch = Mock(return_value=RemoteFetchResult(status_code=200, text=no_lua))
outcome, save = _sync(_binding(), fetch=fetch)
self.assertEqual(outcome.status, STATUS_ERROR)
save.assert_not_called()
def test_local_edit_detaches_without_touching_file(self):
edited = VALID_WINWS2_TEXT + "--filter-tcp=8080\n"
fetch = Mock()
outcome, save = _sync(_binding(), current_text=edited, fetch=fetch)
self.assertEqual(outcome.status, STATUS_DETACHED)
self.assertTrue(outcome.binding_updates["detached"])
fetch.assert_not_called()
save.assert_not_called()
def test_edit_made_during_download_is_not_overwritten(self):
# Проверка «правили локально» идёт до сети; пока файл качался,
# пользователь сохранил правку — её нельзя затирать.
edited = VALID_WINWS2_TEXT + "--filter-tcp=8080\n"
reads = iter([VALID_WINWS2_TEXT, edited])
save = Mock(return_value=None)
outcome = sync_remote_preset(
_binding(),
engine="winws2",
read_current_text=lambda: next(reads),
fetch=Mock(return_value=RemoteFetchResult(status_code=200, text=UPDATED_WINWS2_TEXT)),
save_text=save,
now_iso=NOW_ISO,
)
self.assertEqual(outcome.status, STATUS_DETACHED)
self.assertTrue(outcome.binding_updates["detached"])
save.assert_not_called()
def test_file_deleted_during_download_is_not_recreated(self):
reads = iter([VALID_WINWS2_TEXT, None])
save = Mock(return_value=None)
outcome = sync_remote_preset(
_binding(),
engine="winws2",
read_current_text=lambda: next(reads),
fetch=Mock(return_value=RemoteFetchResult(status_code=200, text=UPDATED_WINWS2_TEXT)),
save_text=save,
now_iso=NOW_ISO,
)
self.assertEqual(outcome.status, STATUS_ERROR)
save.assert_not_called()
def test_detached_binding_is_skipped(self):
edited = VALID_WINWS2_TEXT + "--filter-tcp=8080\n"
outcome, save = _sync(_binding(detached=True), current_text=edited)
self.assertEqual(outcome.status, STATUS_SKIPPED)
self.assertEqual(outcome.binding_updates, {})
save.assert_not_called()
def test_force_overwrites_local_edits_and_reattaches(self):
edited = VALID_WINWS2_TEXT + "--filter-tcp=8080\n"
fetch = Mock(return_value=RemoteFetchResult(status_code=200, text=UPDATED_WINWS2_TEXT))
outcome, save = _sync(_binding(detached=True), current_text=edited, fetch=fetch, force=True)
self.assertEqual(outcome.status, STATUS_UPDATED)
save.assert_called_once_with(UPDATED_WINWS2_TEXT)
self.assertFalse(outcome.binding_updates["detached"])
fetch.assert_called_once_with("https://example.com/p.txt", "", "")
def test_fetch_error_recorded_in_binding(self):
fetch = Mock(side_effect=RemoteSyncFetchError("HTTP 404"))
outcome, save = _sync(_binding(), fetch=fetch)
self.assertEqual(outcome.status, STATUS_ERROR)
self.assertEqual(outcome.binding_updates["error"], "HTTP 404")
save.assert_not_called()
def test_missing_file_is_error(self):
outcome = sync_remote_preset(
_binding(),
engine="winws2",
read_current_text=lambda: None,
fetch=Mock(),
save_text=Mock(),
now_iso=NOW_ISO,
)
self.assertEqual(outcome.status, STATUS_ERROR)
class NormalizedUpstreamComparisonTests(unittest.TestCase):
"""Локальный файл сохранён с нормализацией (обязательный блок --lua-init),
текст источника — без неё. Неизменный источник не должен выглядеть
«обновлённым» при каждой проверке."""
def _local_saved_text(self, text: str) -> str:
from presets.preset_contract import normalize_preset_source_for_save
return normalize_preset_source_for_save(text, "winws2")
def test_upstream_matches_its_own_saved_form(self):
local = self._local_saved_text(VALID_WINWS2_TEXT)
self.assertNotEqual(comparison_hash(local), comparison_hash(VALID_WINWS2_TEXT))
self.assertTrue(upstream_matches_local(VALID_WINWS2_TEXT, local, engine="winws2"))
self.assertTrue(upstream_matches_local(VALID_WINWS2_TEXT.replace("\n", "\r\n"), local, engine="winws2"))
self.assertFalse(upstream_matches_local(UPDATED_WINWS2_TEXT, local, engine="winws2"))
def test_unchanged_upstream_is_unchanged_on_every_check(self):
local = self._local_saved_text(VALID_WINWS2_TEXT)
binding = _binding(synced_hash=comparison_hash(local))
for attempt in range(2):
with self.subTest(attempt=attempt):
fetch = Mock(return_value=RemoteFetchResult(status_code=200, text=VALID_WINWS2_TEXT))
outcome, save = _sync(binding, current_text=local, fetch=fetch)
self.assertEqual(outcome.status, STATUS_UNCHANGED)
save.assert_not_called()
# synced_hash — от файла на диске: следующая проверка не примет
# его за локальную правку и не отвяжет пресет.
self.assertEqual(outcome.binding_updates["synced_hash"], comparison_hash(local))
self.assertFalse(outcome.binding_updates["detached"])
binding = {**binding, **outcome.binding_updates}
def test_old_local_file_without_block_is_not_detached_by_upgrade(self):
# Файл сохранён до обязательного блока, synced_hash посчитан от него.
binding = _binding(synced_hash=comparison_hash(VALID_WINWS2_TEXT))
fetch = Mock(return_value=RemoteFetchResult(status_code=200, text=VALID_WINWS2_TEXT))
outcome, save = _sync(binding, current_text=VALID_WINWS2_TEXT, fetch=fetch)
self.assertEqual(outcome.status, STATUS_UNCHANGED)
save.assert_not_called()
self.assertEqual(outcome.binding_updates["synced_hash"], comparison_hash(VALID_WINWS2_TEXT))
class _FakeResponse:
def __init__(self, *, status_code=200, headers=None, chunks=None):
self.status_code = status_code
self.headers = dict(headers or {})
self._chunks = list(chunks or [])
self.closed = False
def iter_content(self, chunk_size):
yield from self._chunks
def close(self):
self.closed = True
class FetchRemotePresetTextTests(unittest.TestCase):
def _patch(self, response=None, error=None):
def _fake(url, **kwargs):
if error is not None:
raise error
self.last_kwargs = kwargs
return response
return patch("presets.remote_sync.request_get_bypass_proxy", side_effect=_fake)
def test_https_only(self):
with self.assertRaises(RemoteSyncFetchError):
fetch_remote_preset_text("http://example.com/p.txt")
def test_conditional_headers_sent(self):
response = _FakeResponse(status_code=304)
with self._patch(response):
result = fetch_remote_preset_text(
"https://example.com/p.txt", etag='W/"9"', last_modified="Thu, 01 Jan 2026"
)
self.assertEqual(result.status_code, 304)
headers = self.last_kwargs["headers"]
self.assertEqual(headers["If-None-Match"], 'W/"9"')
self.assertEqual(headers["If-Modified-Since"], "Thu, 01 Jan 2026")
def test_success_returns_text_and_validators(self):
response = _FakeResponse(
headers={"ETag": 'W/"5"', "Last-Modified": "Fri, 02 Jan 2026"},
chunks=[VALID_WINWS2_TEXT.encode("utf-8")],
)
with self._patch(response):
result = fetch_remote_preset_text("https://example.com/p.txt")
self.assertEqual(result.text, VALID_WINWS2_TEXT)
self.assertEqual(result.etag, 'W/"5"')
self.assertEqual(result.last_modified, "Fri, 02 Jan 2026")
self.assertTrue(response.closed)
def test_utf8_bom_is_decoded_as_encoding(self):
response = _FakeResponse(chunks=[b"\xef\xbb\xbf" + VALID_WINWS2_TEXT.encode("utf-8")])
with self._patch(response):
result = fetch_remote_preset_text("https://example.com/p.txt")
self.assertEqual(result.text, VALID_WINWS2_TEXT)
def test_size_limit(self):
big = b"x" * (MAX_REMOTE_PRESET_BYTES + 1)
with self._patch(_FakeResponse(chunks=[big])):
with self.assertRaises(RemoteSyncFetchError):
fetch_remote_preset_text("https://example.com/p.txt")
def test_zip_content_rejected(self):
with self._patch(_FakeResponse(chunks=[b"PK\x03\x04zip"])):
with self.assertRaises(RemoteSyncFetchError):
fetch_remote_preset_text("https://example.com/p.txt")
def test_http_status_error(self):
with self._patch(_FakeResponse(status_code=500)):
with self.assertRaises(RemoteSyncFetchError):
fetch_remote_preset_text("https://example.com/p.txt")
def test_network_error(self):
with self._patch(error=requests.exceptions.ConnectionError("refused")):
with self.assertRaises(RemoteSyncFetchError):
fetch_remote_preset_text("https://example.com/p.txt")
if __name__ == "__main__":
unittest.main()