/ tests / gateway / test_telegram_network.py
test_telegram_network.py
  1  """Tests for gateway.platforms.telegram_network – fallback transport layer.
  2  
  3  Background
  4  ----------
  5  api.telegram.org resolves to an IP (e.g. 149.154.166.110) that is unreachable
  6  from some networks.  The workaround: route TCP through a different IP in the
  7  same Telegram-owned 149.154.160.0/20 block (e.g. 149.154.167.220) while
  8  keeping TLS SNI and the Host header as api.telegram.org so Telegram's edge
  9  servers still accept the request.  This is the programmatic equivalent of:
 10  
 11      curl --resolve api.telegram.org:443:149.154.167.220 https://api.telegram.org/bot<token>/getMe
 12  
 13  The TelegramFallbackTransport implements this: try the primary (DNS-resolved)
 14  path first, and on ConnectTimeout / ConnectError fall through to configured
 15  fallback IPs in order, then "stick" to whichever IP works.
 16  """
 17  
 18  import httpx
 19  import pytest
 20  
 21  from gateway.platforms import telegram_network as tnet
 22  
 23  
 24  # ---------------------------------------------------------------------------
 25  # Helpers
 26  # ---------------------------------------------------------------------------
 27  
 28  class FakeTransport(httpx.AsyncBaseTransport):
 29      """Records calls and raises / returns based on a host→action mapping."""
 30  
 31      def __init__(self, calls, behavior):
 32          self.calls = calls
 33          self.behavior = behavior
 34          self.closed = False
 35  
 36      async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
 37          self.calls.append(
 38              {
 39                  "url_host": request.url.host,
 40                  "host_header": request.headers.get("host"),
 41                  "sni_hostname": request.extensions.get("sni_hostname"),
 42                  "path": request.url.path,
 43              }
 44          )
 45          action = self.behavior.get(request.url.host, "ok")
 46          if action == "timeout":
 47              raise httpx.ConnectTimeout("timed out")
 48          if action == "connect_error":
 49              raise httpx.ConnectError("connect error")
 50          if isinstance(action, Exception):
 51              raise action
 52          return httpx.Response(200, request=request, text="ok")
 53  
 54      async def aclose(self) -> None:
 55          self.closed = True
 56  
 57  
 58  def _fake_transport_factory(calls, behavior):
 59      """Returns a factory that creates FakeTransport instances."""
 60      instances = []
 61  
 62      def factory(**kwargs):
 63          t = FakeTransport(calls, behavior)
 64          instances.append(t)
 65          return t
 66  
 67      factory.instances = instances
 68      return factory
 69  
 70  
 71  def _telegram_request(path="/botTOKEN/getMe"):
 72      return httpx.Request("GET", f"https://api.telegram.org{path}")
 73  
 74  
 75  # ═══════════════════════════════════════════════════════════════════════════
 76  # IP parsing & validation
 77  # ═══════════════════════════════════════════════════════════════════════════
 78  
 79  class TestParseFallbackIpEnv:
 80      def test_filters_invalid_and_ipv6(self, caplog):
 81          ips = tnet.parse_fallback_ip_env("149.154.167.220, bad, 2001:67c:4e8:f004::9,149.154.167.220")
 82          assert ips == ["149.154.167.220", "149.154.167.220"]
 83          assert "Ignoring invalid Telegram fallback IP" in caplog.text
 84          assert "Ignoring non-IPv4 Telegram fallback IP" in caplog.text
 85  
 86      def test_none_returns_empty(self):
 87          assert tnet.parse_fallback_ip_env(None) == []
 88  
 89      def test_empty_string_returns_empty(self):
 90          assert tnet.parse_fallback_ip_env("") == []
 91  
 92      def test_whitespace_only_returns_empty(self):
 93          assert tnet.parse_fallback_ip_env("  ,  , ") == []
 94  
 95      def test_single_valid_ip(self):
 96          assert tnet.parse_fallback_ip_env("149.154.167.220") == ["149.154.167.220"]
 97  
 98      def test_multiple_valid_ips(self):
 99          ips = tnet.parse_fallback_ip_env("149.154.167.220, 149.154.167.221")
100          assert ips == ["149.154.167.220", "149.154.167.221"]
101  
102      def test_rejects_leading_zeros(self, caplog):
103          """Leading zeros are ambiguous (octal?) so ipaddress rejects them."""
104          ips = tnet.parse_fallback_ip_env("149.154.167.010")
105          assert ips == []
106          assert "Ignoring invalid" in caplog.text
107  
108  
109  class TestNormalizeFallbackIps:
110      def test_deduplication_happens_at_transport_level(self):
111          """_normalize does not dedup; TelegramFallbackTransport.__init__ does."""
112          raw = ["149.154.167.220", "149.154.167.220"]
113          assert tnet._normalize_fallback_ips(raw) == ["149.154.167.220", "149.154.167.220"]
114  
115      def test_empty_strings_skipped(self):
116          assert tnet._normalize_fallback_ips(["", "  ", "149.154.167.220"]) == ["149.154.167.220"]
117  
118  
119  # ═══════════════════════════════════════════════════════════════════════════
120  # Request rewriting
121  # ═══════════════════════════════════════════════════════════════════════════
122  
123  class TestRewriteRequestForIp:
124      def test_preserves_host_and_sni(self):
125          request = _telegram_request()
126          rewritten = tnet._rewrite_request_for_ip(request, "149.154.167.220")
127  
128          assert rewritten.url.host == "149.154.167.220"
129          assert rewritten.headers["host"] == "api.telegram.org"
130          assert rewritten.extensions["sni_hostname"] == "api.telegram.org"
131          assert rewritten.url.path == "/botTOKEN/getMe"
132  
133      def test_preserves_method_and_path(self):
134          request = httpx.Request("POST", "https://api.telegram.org/botTOKEN/sendMessage")
135          rewritten = tnet._rewrite_request_for_ip(request, "149.154.167.220")
136  
137          assert rewritten.method == "POST"
138          assert rewritten.url.path == "/botTOKEN/sendMessage"
139  
140  
141  # ═══════════════════════════════════════════════════════════════════════════
142  # Fallback transport – core behavior
143  # ═══════════════════════════════════════════════════════════════════════════
144  
145  class TestFallbackTransport:
146      """Primary path fails → try fallback IPs → stick to whichever works."""
147  
148      @pytest.mark.asyncio
149      async def test_falls_back_on_connect_timeout_and_becomes_sticky(self, monkeypatch):
150          calls = []
151          behavior = {"api.telegram.org": "timeout", "149.154.167.220": "ok"}
152          monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior))
153  
154          transport = tnet.TelegramFallbackTransport(["149.154.167.220"])
155          resp = await transport.handle_async_request(_telegram_request())
156  
157          assert resp.status_code == 200
158          assert transport._sticky_ip == "149.154.167.220"
159          # First attempt was primary (api.telegram.org), second was fallback
160          assert calls[0]["url_host"] == "api.telegram.org"
161          assert calls[1]["url_host"] == "149.154.167.220"
162          assert calls[1]["host_header"] == "api.telegram.org"
163          assert calls[1]["sni_hostname"] == "api.telegram.org"
164  
165          # Second request goes straight to sticky IP
166          calls.clear()
167          resp2 = await transport.handle_async_request(_telegram_request())
168          assert resp2.status_code == 200
169          assert calls[0]["url_host"] == "149.154.167.220"
170  
171      @pytest.mark.asyncio
172      async def test_falls_back_on_connect_error(self, monkeypatch):
173          calls = []
174          behavior = {"api.telegram.org": "connect_error", "149.154.167.220": "ok"}
175          monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior))
176  
177          transport = tnet.TelegramFallbackTransport(["149.154.167.220"])
178          resp = await transport.handle_async_request(_telegram_request())
179  
180          assert resp.status_code == 200
181          assert transport._sticky_ip == "149.154.167.220"
182  
183      @pytest.mark.asyncio
184      async def test_does_not_fallback_on_non_connect_error(self, monkeypatch):
185          """Errors like ReadTimeout are not connection issues — don't retry."""
186          calls = []
187          behavior = {"api.telegram.org": httpx.ReadTimeout("read timeout"), "149.154.167.220": "ok"}
188          monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior))
189  
190          transport = tnet.TelegramFallbackTransport(["149.154.167.220"])
191  
192          with pytest.raises(httpx.ReadTimeout):
193              await transport.handle_async_request(_telegram_request())
194  
195          assert [c["url_host"] for c in calls] == ["api.telegram.org"]
196  
197      @pytest.mark.asyncio
198      async def test_all_ips_fail_raises_last_error(self, monkeypatch):
199          calls = []
200          behavior = {"api.telegram.org": "timeout", "149.154.167.220": "timeout"}
201          monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior))
202  
203          transport = tnet.TelegramFallbackTransport(["149.154.167.220"])
204  
205          with pytest.raises(httpx.ConnectTimeout):
206              await transport.handle_async_request(_telegram_request())
207  
208          assert [c["url_host"] for c in calls] == ["api.telegram.org", "149.154.167.220"]
209          assert transport._sticky_ip is None
210  
211      @pytest.mark.asyncio
212      async def test_multiple_fallback_ips_tried_in_order(self, monkeypatch):
213          calls = []
214          behavior = {
215              "api.telegram.org": "timeout",
216              "149.154.167.220": "timeout",
217              "149.154.167.221": "ok",
218          }
219          monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior))
220  
221          transport = tnet.TelegramFallbackTransport(["149.154.167.220", "149.154.167.221"])
222          resp = await transport.handle_async_request(_telegram_request())
223  
224          assert resp.status_code == 200
225          assert transport._sticky_ip == "149.154.167.221"
226          assert [c["url_host"] for c in calls] == [
227              "api.telegram.org",
228              "149.154.167.220",
229              "149.154.167.221",
230          ]
231  
232      @pytest.mark.asyncio
233      async def test_sticky_ip_tried_first_but_falls_through_if_stale(self, monkeypatch):
234          """If the sticky IP stops working, the transport retries others."""
235          calls = []
236          behavior = {
237              "api.telegram.org": "timeout",
238              "149.154.167.220": "ok",
239              "149.154.167.221": "ok",
240          }
241          monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior))
242  
243          transport = tnet.TelegramFallbackTransport(["149.154.167.220", "149.154.167.221"])
244  
245          # First request: primary fails → .220 works → becomes sticky
246          await transport.handle_async_request(_telegram_request())
247          assert transport._sticky_ip == "149.154.167.220"
248  
249          # Now .220 goes bad too
250          calls.clear()
251          behavior["149.154.167.220"] = "timeout"
252  
253          resp = await transport.handle_async_request(_telegram_request())
254          assert resp.status_code == 200
255          # Tried sticky (.220) first, then fell through to .221
256          assert [c["url_host"] for c in calls] == ["149.154.167.220", "149.154.167.221"]
257          assert transport._sticky_ip == "149.154.167.221"
258  
259  
260  class TestFallbackTransportPassthrough:
261      """Requests that don't need fallback behavior."""
262  
263      @pytest.mark.asyncio
264      async def test_non_telegram_host_bypasses_fallback(self, monkeypatch):
265          calls = []
266          behavior = {}
267          monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior))
268  
269          transport = tnet.TelegramFallbackTransport(["149.154.167.220"])
270          request = httpx.Request("GET", "https://example.com/path")
271          resp = await transport.handle_async_request(request)
272  
273          assert resp.status_code == 200
274          assert calls[0]["url_host"] == "example.com"
275          assert transport._sticky_ip is None
276  
277      @pytest.mark.asyncio
278      async def test_empty_fallback_list_uses_primary_only(self, monkeypatch):
279          calls = []
280          behavior = {}
281          monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior))
282  
283          transport = tnet.TelegramFallbackTransport([])
284          resp = await transport.handle_async_request(_telegram_request())
285  
286          assert resp.status_code == 200
287          assert calls[0]["url_host"] == "api.telegram.org"
288  
289      @pytest.mark.asyncio
290      async def test_primary_succeeds_no_fallback_needed(self, monkeypatch):
291          calls = []
292          behavior = {"api.telegram.org": "ok"}
293          monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior))
294  
295          transport = tnet.TelegramFallbackTransport(["149.154.167.220"])
296          resp = await transport.handle_async_request(_telegram_request())
297  
298          assert resp.status_code == 200
299          assert transport._sticky_ip is None
300          assert len(calls) == 1
301  
302  
303  class TestFallbackTransportInit:
304      def test_deduplicates_fallback_ips(self, monkeypatch):
305          monkeypatch.setattr(
306              tnet.httpx, "AsyncHTTPTransport", lambda **kw: FakeTransport([], {})
307          )
308          transport = tnet.TelegramFallbackTransport(["149.154.167.220", "149.154.167.220"])
309          assert transport._fallback_ips == ["149.154.167.220"]
310  
311      def test_filters_invalid_ips_at_init(self, monkeypatch):
312          monkeypatch.setattr(
313              tnet.httpx, "AsyncHTTPTransport", lambda **kw: FakeTransport([], {})
314          )
315          transport = tnet.TelegramFallbackTransport(["149.154.167.220", "not-an-ip"])
316          assert transport._fallback_ips == ["149.154.167.220"]
317  
318      def test_uses_proxy_env_for_primary_and_fallback_transports(self, monkeypatch):
319          seen_kwargs = []
320  
321          def factory(**kwargs):
322              seen_kwargs.append(kwargs.copy())
323              return FakeTransport([], {})
324  
325          for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy", "TELEGRAM_PROXY", "NO_PROXY", "no_proxy"):
326              monkeypatch.delenv(key, raising=False)
327          monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
328          monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", factory)
329  
330          transport = tnet.TelegramFallbackTransport(["149.154.167.220"])
331  
332          assert transport._fallback_ips == ["149.154.167.220"]
333          assert len(seen_kwargs) == 2
334          assert all(kwargs["proxy"] == "http://proxy.example:8080" for kwargs in seen_kwargs)
335  
336      def test_no_proxy_bypasses_fallback_ip_cidr(self, monkeypatch):
337          seen_kwargs = []
338  
339          def factory(**kwargs):
340              seen_kwargs.append(kwargs.copy())
341              return FakeTransport([], {})
342  
343          for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy", "TELEGRAM_PROXY", "NO_PROXY", "no_proxy"):
344              monkeypatch.delenv(key, raising=False)
345          monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
346          monkeypatch.setenv("NO_PROXY", "149.154.160.0/20")
347          monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", factory)
348  
349          transport = tnet.TelegramFallbackTransport(["149.154.167.220"])
350  
351          assert transport._fallback_ips == ["149.154.167.220"]
352          assert len(seen_kwargs) == 2
353          assert all("proxy" not in kwargs for kwargs in seen_kwargs)
354  
355  
356  class TestFallbackTransportClose:
357      @pytest.mark.asyncio
358      async def test_aclose_closes_all_transports(self, monkeypatch):
359          factory = _fake_transport_factory([], {})
360          monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", factory)
361  
362          transport = tnet.TelegramFallbackTransport(["149.154.167.220", "149.154.167.221"])
363          await transport.aclose()
364  
365          # 1 primary + 2 fallback transports
366          assert len(factory.instances) == 3
367          assert all(t.closed for t in factory.instances)
368  
369  
370  # ═══════════════════════════════════════════════════════════════════════════
371  # Config layer – TELEGRAM_FALLBACK_IPS env → config.extra
372  # ═══════════════════════════════════════════════════════════════════════════
373  
374  class TestConfigFallbackIps:
375      def test_env_var_populates_config_extra(self, monkeypatch):
376          from gateway.config import GatewayConfig, Platform, PlatformConfig, _apply_env_overrides
377  
378          monkeypatch.setenv("TELEGRAM_FALLBACK_IPS", "149.154.167.220,149.154.167.221")
379          config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="tok")})
380          _apply_env_overrides(config)
381  
382          assert config.platforms[Platform.TELEGRAM].extra["fallback_ips"] == [
383              "149.154.167.220", "149.154.167.221",
384          ]
385  
386      def test_env_var_creates_platform_if_missing(self, monkeypatch):
387          from gateway.config import GatewayConfig, Platform, _apply_env_overrides
388  
389          monkeypatch.setenv("TELEGRAM_FALLBACK_IPS", "149.154.167.220")
390          config = GatewayConfig(platforms={})
391          _apply_env_overrides(config)
392  
393          assert Platform.TELEGRAM in config.platforms
394          assert config.platforms[Platform.TELEGRAM].extra["fallback_ips"] == ["149.154.167.220"]
395  
396      def test_env_var_strips_whitespace(self, monkeypatch):
397          from gateway.config import GatewayConfig, Platform, PlatformConfig, _apply_env_overrides
398  
399          monkeypatch.setenv("TELEGRAM_FALLBACK_IPS", "  149.154.167.220 , 149.154.167.221  ")
400          config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="tok")})
401          _apply_env_overrides(config)
402  
403          assert config.platforms[Platform.TELEGRAM].extra["fallback_ips"] == [
404              "149.154.167.220", "149.154.167.221",
405          ]
406  
407      def test_empty_env_var_does_not_populate(self, monkeypatch):
408          from gateway.config import GatewayConfig, Platform, PlatformConfig, _apply_env_overrides
409  
410          monkeypatch.setenv("TELEGRAM_FALLBACK_IPS", "")
411          config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="tok")})
412          _apply_env_overrides(config)
413  
414          assert "fallback_ips" not in config.platforms[Platform.TELEGRAM].extra
415  
416  
417  # ═══════════════════════════════════════════════════════════════════════════
418  # Adapter layer – _fallback_ips() reads config correctly
419  # ═══════════════════════════════════════════════════════════════════════════
420  
421  class TestAdapterFallbackIps:
422      def _make_adapter(self, extra=None):
423          import sys
424          from unittest.mock import MagicMock
425  
426          # Ensure telegram mock is in place
427          if "telegram" not in sys.modules or not hasattr(sys.modules["telegram"], "__file__"):
428              mod = MagicMock()
429              mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
430              mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
431              mod.constants.ChatType.GROUP = "group"
432              mod.constants.ChatType.SUPERGROUP = "supergroup"
433              mod.constants.ChatType.CHANNEL = "channel"
434              mod.constants.ChatType.PRIVATE = "private"
435              for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
436                  sys.modules.setdefault(name, mod)
437  
438          from gateway.config import PlatformConfig
439          from gateway.platforms.telegram import TelegramAdapter
440  
441          config = PlatformConfig(enabled=True, token="test-token")
442          if extra:
443              config.extra.update(extra)
444          return TelegramAdapter(config)
445  
446      def test_list_in_extra(self):
447          adapter = self._make_adapter(extra={"fallback_ips": ["149.154.167.220"]})
448          assert adapter._fallback_ips() == ["149.154.167.220"]
449  
450      def test_csv_string_in_extra(self):
451          adapter = self._make_adapter(extra={"fallback_ips": "149.154.167.220,149.154.167.221"})
452          assert adapter._fallback_ips() == ["149.154.167.220", "149.154.167.221"]
453  
454      def test_empty_extra(self):
455          adapter = self._make_adapter()
456          assert adapter._fallback_ips() == []
457  
458      def test_no_extra_attr(self):
459          adapter = self._make_adapter()
460          adapter.config.extra = None
461          assert adapter._fallback_ips() == []
462  
463      def test_invalid_ips_filtered(self):
464          adapter = self._make_adapter(extra={"fallback_ips": ["149.154.167.220", "not-valid"]})
465          assert adapter._fallback_ips() == ["149.154.167.220"]
466  
467  
468  # ═══════════════════════════════════════════════════════════════════════════
469  # DoH auto-discovery
470  # ═══════════════════════════════════════════════════════════════════════════
471  
472  def _doh_answer(*ips: str) -> dict:
473      """Build a minimal DoH JSON response with A records."""
474      return {"Answer": [{"type": 1, "data": ip} for ip in ips]}
475  
476  
477  class FakeDoHClient:
478      """Mock httpx.AsyncClient for DoH queries."""
479  
480      def __init__(self, responses: dict):
481          # responses: URL prefix → (status, json_body) | Exception
482          self._responses = responses
483          self.requests_made: list[dict] = []
484  
485      @staticmethod
486      def _make_response(status, body, url):
487          """Build an httpx.Response with a request attached (needed for raise_for_status)."""
488          request = httpx.Request("GET", url)
489          return httpx.Response(status, json=body, request=request)
490  
491      async def get(self, url, *, params=None, headers=None, **kwargs):
492          self.requests_made.append({"url": url, "params": params, "headers": headers})
493          for prefix, action in self._responses.items():
494              if url.startswith(prefix):
495                  if isinstance(action, Exception):
496                      raise action
497                  status, body = action
498                  return self._make_response(status, body, url)
499          return self._make_response(200, {}, url)
500  
501      async def __aenter__(self):
502          return self
503  
504      async def __aexit__(self, *args):
505          pass
506  
507  
508  class TestDiscoverFallbackIps:
509      """Tests for discover_fallback_ips() — DoH-based auto-discovery."""
510  
511      def _patch_doh(self, monkeypatch, responses, system_dns_ips=None):
512          """Wire up fake DoH client and system DNS."""
513          client = FakeDoHClient(responses)
514          monkeypatch.setattr(tnet.httpx, "AsyncClient", lambda **kw: client)
515  
516          if system_dns_ips is not None:
517              addrs = [(None, None, None, None, (ip, 443)) for ip in system_dns_ips]
518              monkeypatch.setattr(tnet.socket, "getaddrinfo", lambda *a, **kw: addrs)
519          else:
520              def _fail(*a, **kw):
521                  raise OSError("dns failed")
522              monkeypatch.setattr(tnet.socket, "getaddrinfo", _fail)
523          return client
524  
525      @pytest.mark.asyncio
526      async def test_google_and_cloudflare_ips_collected(self, monkeypatch):
527          self._patch_doh(monkeypatch, {
528              "https://dns.google": (200, _doh_answer("149.154.167.220")),
529              "https://cloudflare-dns.com": (200, _doh_answer("149.154.167.221")),
530          }, system_dns_ips=["149.154.166.110"])
531  
532          ips = await tnet.discover_fallback_ips()
533          assert "149.154.167.220" in ips
534          assert "149.154.167.221" in ips
535  
536      @pytest.mark.asyncio
537      async def test_system_dns_ip_excluded(self, monkeypatch):
538          """The IP from system DNS is the one that doesn't work — exclude it."""
539          self._patch_doh(monkeypatch, {
540              "https://dns.google": (200, _doh_answer("149.154.166.110", "149.154.167.220")),
541              "https://cloudflare-dns.com": (200, _doh_answer("149.154.166.110")),
542          }, system_dns_ips=["149.154.166.110"])
543  
544          ips = await tnet.discover_fallback_ips()
545          assert ips == ["149.154.167.220"]
546  
547      @pytest.mark.asyncio
548      async def test_doh_results_deduplicated(self, monkeypatch):
549          self._patch_doh(monkeypatch, {
550              "https://dns.google": (200, _doh_answer("149.154.167.220")),
551              "https://cloudflare-dns.com": (200, _doh_answer("149.154.167.220")),
552          }, system_dns_ips=["149.154.166.110"])
553  
554          ips = await tnet.discover_fallback_ips()
555          assert ips == ["149.154.167.220"]
556  
557      @pytest.mark.asyncio
558      async def test_doh_timeout_falls_back_to_seed(self, monkeypatch):
559          self._patch_doh(monkeypatch, {
560              "https://dns.google": httpx.TimeoutException("timeout"),
561              "https://cloudflare-dns.com": httpx.TimeoutException("timeout"),
562          }, system_dns_ips=["149.154.166.110"])
563  
564          ips = await tnet.discover_fallback_ips()
565          assert ips == tnet._SEED_FALLBACK_IPS
566  
567      @pytest.mark.asyncio
568      async def test_doh_connect_error_falls_back_to_seed(self, monkeypatch):
569          self._patch_doh(monkeypatch, {
570              "https://dns.google": httpx.ConnectError("refused"),
571              "https://cloudflare-dns.com": httpx.ConnectError("refused"),
572          }, system_dns_ips=["149.154.166.110"])
573  
574          ips = await tnet.discover_fallback_ips()
575          assert ips == tnet._SEED_FALLBACK_IPS
576  
577      @pytest.mark.asyncio
578      async def test_doh_malformed_json_falls_back_to_seed(self, monkeypatch):
579          self._patch_doh(monkeypatch, {
580              "https://dns.google": (200, {"Status": 0}),  # no Answer key
581              "https://cloudflare-dns.com": (200, {"garbage": True}),
582          }, system_dns_ips=["149.154.166.110"])
583  
584          ips = await tnet.discover_fallback_ips()
585          assert ips == tnet._SEED_FALLBACK_IPS
586  
587      @pytest.mark.asyncio
588      async def test_one_provider_fails_other_succeeds(self, monkeypatch):
589          self._patch_doh(monkeypatch, {
590              "https://dns.google": httpx.TimeoutException("timeout"),
591              "https://cloudflare-dns.com": (200, _doh_answer("149.154.167.220")),
592          }, system_dns_ips=["149.154.166.110"])
593  
594          ips = await tnet.discover_fallback_ips()
595          assert ips == ["149.154.167.220"]
596  
597      @pytest.mark.asyncio
598      async def test_system_dns_failure_keeps_all_doh_ips(self, monkeypatch):
599          """If system DNS fails, nothing gets excluded — all DoH IPs kept."""
600          self._patch_doh(monkeypatch, {
601              "https://dns.google": (200, _doh_answer("149.154.166.110", "149.154.167.220")),
602              "https://cloudflare-dns.com": (200, _doh_answer()),
603          }, system_dns_ips=None)  # triggers OSError
604  
605          ips = await tnet.discover_fallback_ips()
606          assert "149.154.166.110" in ips
607          assert "149.154.167.220" in ips
608  
609      @pytest.mark.asyncio
610      async def test_all_doh_ips_same_as_system_dns_uses_seed(self, monkeypatch):
611          """DoH returns only the same blocked IP — seed list is the fallback."""
612          self._patch_doh(monkeypatch, {
613              "https://dns.google": (200, _doh_answer("149.154.166.110")),
614              "https://cloudflare-dns.com": (200, _doh_answer("149.154.166.110")),
615          }, system_dns_ips=["149.154.166.110"])
616  
617          ips = await tnet.discover_fallback_ips()
618          assert ips == tnet._SEED_FALLBACK_IPS
619  
620      @pytest.mark.asyncio
621      async def test_cloudflare_gets_accept_header(self, monkeypatch):
622          client = self._patch_doh(monkeypatch, {
623              "https://dns.google": (200, _doh_answer("149.154.167.220")),
624              "https://cloudflare-dns.com": (200, _doh_answer("149.154.167.221")),
625          }, system_dns_ips=["149.154.166.110"])
626  
627          await tnet.discover_fallback_ips()
628  
629          cf_reqs = [r for r in client.requests_made if "cloudflare" in r["url"]]
630          assert cf_reqs
631          assert cf_reqs[0]["headers"]["Accept"] == "application/dns-json"
632  
633      @pytest.mark.asyncio
634      async def test_non_a_records_ignored(self, monkeypatch):
635          """AAAA records (type 28) and CNAME (type 5) should be skipped."""
636          answer = {
637              "Answer": [
638                  {"type": 5, "data": "telegram.org"},  # CNAME
639                  {"type": 28, "data": "2001:67c:4e8:f004::9"},  # AAAA
640                  {"type": 1, "data": "149.154.167.220"},  # A ✓
641              ]
642          }
643          self._patch_doh(monkeypatch, {
644              "https://dns.google": (200, answer),
645              "https://cloudflare-dns.com": (200, _doh_answer()),
646          }, system_dns_ips=["149.154.166.110"])
647  
648          ips = await tnet.discover_fallback_ips()
649          assert ips == ["149.154.167.220"]
650  
651      @pytest.mark.asyncio
652      async def test_invalid_ip_in_doh_response_skipped(self, monkeypatch):
653          answer = {"Answer": [
654              {"type": 1, "data": "not-an-ip"},
655              {"type": 1, "data": "149.154.167.220"},
656          ]}
657          self._patch_doh(monkeypatch, {
658              "https://dns.google": (200, answer),
659              "https://cloudflare-dns.com": (200, _doh_answer()),
660          }, system_dns_ips=["149.154.166.110"])
661  
662          ips = await tnet.discover_fallback_ips()
663          assert ips == ["149.154.167.220"]