"""Tests for geo_service and GeoCache.""" from __future__ import annotations import asyncio from collections.abc import Mapping, Sequence from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.models.geo import GeoInfo from app.services.geo_cache import ( _BATCH_DELAY, _BATCH_MAX_RETRIES, _BATCH_SIZE, _NEG_CACHE_TTL, GeoCache, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_session(response_json: dict[str, object], status: int = 200) -> MagicMock: """Build a mock aiohttp.ClientSession that returns *response_json*. Args: response_json: The dict that the mock response's ``json()`` returns. status: HTTP status code for the mock response. Returns: A :class:`MagicMock` that behaves like an ``aiohttp.ClientSession`` in an ``async with`` context. """ mock_resp = AsyncMock() mock_resp.status = status mock_resp.json = AsyncMock(return_value=response_json) mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_resp) mock_ctx.__aexit__ = AsyncMock(return_value=False) session = MagicMock() session.get = MagicMock(return_value=mock_ctx) return session # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture async def geo_cache() -> GeoCache: """Provide a fresh GeoCache instance for each test with HTTP fallback enabled. Most tests expect HTTP API to be available and do not set up MMDB. For testing MMDB-first behavior, use geo_cache_mmdb_only fixture instead. """ return GeoCache(allow_http_fallback=True) @pytest.fixture async def geo_cache_mmdb_only() -> GeoCache: """Provide a fresh GeoCache instance with HTTP fallback disabled (MMDB-only mode).""" return GeoCache(allow_http_fallback=False) def test_init_geoip_is_startup_only(geo_cache: GeoCache, tmp_path) -> None: """A second init_geoip() call raises when the reader was already loaded.""" path = tmp_path / "GeoLite2-Country.mmdb" path.write_text("dummy") with patch("geoip2.database.Reader", MagicMock(name="Reader")) as mock_reader: geo_cache.init_geoip(str(path)) assert geo_cache._geoip_reader is not None assert geo_cache._geoip_initialized is True with pytest.raises(RuntimeError, match="already initialised"): geo_cache.init_geoip(str(path)) assert mock_reader.call_count == 1 def test_init_geoip_no_path_leaves_reader_uninitialised(geo_cache: GeoCache) -> None: """No active reader is created when no path is supplied.""" geo_cache.init_geoip("") assert geo_cache._geoip_reader is None assert geo_cache._geoip_initialized is False # --------------------------------------------------------------------------- # Happy path # --------------------------------------------------------------------------- class TestLookupSuccess: """geo_service.lookup() under normal conditions.""" async def test_returns_country_code(self, geo_cache: GeoCache) -> None: """country_code is populated from the ``countryCode`` field.""" session = _make_session( { "status": "success", "countryCode": "DE", "country": "Germany", "as": "AS3320 Deutsche Telekom AG", "org": "AS3320 Deutsche Telekom AG", } ) result = await geo_cache.lookup("1.2.3.4", session) assert result is not None assert result.country_code == "DE" async def test_returns_country_name(self, geo_cache: GeoCache) -> None: """country_name is populated from the ``country`` field.""" session = _make_session( { "status": "success", "countryCode": "US", "country": "United States", "as": "AS15169 Google LLC", "org": "Google LLC", } ) result = await geo_cache.lookup("8.8.8.8", session) assert result is not None assert result.country_name == "United States" async def test_asn_extracted_without_org_suffix(self, geo_cache: GeoCache) -> None: """The ASN field contains only the ``AS`` prefix, not the full string.""" session = _make_session( { "status": "success", "countryCode": "DE", "country": "Germany", "as": "AS3320 Deutsche Telekom AG", "org": "Deutsche Telekom", } ) result = await geo_cache.lookup("1.2.3.4", session) assert result is not None assert result.asn == "AS3320" async def test_org_populated(self, geo_cache: GeoCache) -> None: """org field is populated from the ``org`` key.""" session = _make_session( { "status": "success", "countryCode": "US", "country": "United States", "as": "AS15169 Google LLC", "org": "Google LLC", } ) result = await geo_cache.lookup("8.8.8.8", session) assert result is not None assert result.org == "Google LLC" # --------------------------------------------------------------------------- # Cache behaviour # --------------------------------------------------------------------------- class TestLookupCaching: """Verify that results are cached and the cache can be cleared.""" async def test_second_call_uses_cache(self, geo_cache: GeoCache) -> None: """Subsequent lookups for the same IP do not make additional HTTP requests.""" session = _make_session( { "status": "success", "countryCode": "DE", "country": "Germany", "as": "AS3320 Deutsche Telekom AG", "org": "Deutsche Telekom", } ) await geo_cache.lookup("1.2.3.4", session) await geo_cache.lookup("1.2.3.4", session) # The session.get() should only have been called once. assert session.get.call_count == 1 async def test_clear_cache_forces_refetch(self, geo_cache: GeoCache) -> None: """After clearing the cache a new HTTP request is made.""" session = _make_session( { "status": "success", "countryCode": "DE", "country": "Germany", "as": "AS3320", "org": "Telekom", } ) await geo_cache.lookup("2.3.4.5", session) await geo_cache.clear() await geo_cache.lookup("2.3.4.5", session) assert session.get.call_count == 2 async def test_negative_result_stored_in_neg_cache(self, geo_cache: GeoCache) -> None: """A failed lookup is stored in the negative cache, so the second call is blocked.""" session = _make_session( {"status": "fail", "message": "reserved range"} ) await geo_cache.lookup("192.168.1.1", session) await geo_cache.lookup("192.168.1.1", session) # Second call is blocked by the negative cache — only one API hit. assert session.get.call_count == 1 # --------------------------------------------------------------------------- # Failure modes # --------------------------------------------------------------------------- class TestLookupFailures: """geo_service.lookup() when things go wrong.""" async def test_non_200_response_returns_null_geo_info(self, geo_cache: GeoCache) -> None: """A 429 or 500 status returns GeoInfo with null fields (not None).""" session = _make_session({}, status=429) result = await geo_cache.lookup("1.2.3.4", session) assert result is not None assert isinstance(result, GeoInfo) assert result.country_code is None async def test_network_error_returns_null_geo_info(self, geo_cache: GeoCache) -> None: """A network exception returns GeoInfo with null fields (not None).""" session = MagicMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(side_effect=OSError("connection refused")) mock_ctx.__aexit__ = AsyncMock(return_value=False) session.get = MagicMock(return_value=mock_ctx) result = await geo_cache.lookup("10.0.0.1", session) assert result is not None assert isinstance(result, GeoInfo) assert result.country_code is None async def test_failed_status_returns_geo_info_with_nulls(self, geo_cache: GeoCache) -> None: """When ip-api returns ``status=fail`` a GeoInfo with null fields is returned (but not cached).""" session = _make_session({"status": "fail", "message": "private range"}) result = await geo_cache.lookup("10.0.0.1", session) assert result is not None assert isinstance(result, GeoInfo) assert result.country_code is None assert result.country_name is None # --------------------------------------------------------------------------- # Negative cache # --------------------------------------------------------------------------- class TestNegativeCache: """Verify the negative cache throttles retries for failing IPs.""" async def test_neg_cache_blocks_second_lookup(self, geo_cache: GeoCache) -> None: """After a failed lookup the second call is served from the neg cache.""" session = _make_session({"status": "fail", "message": "private range"}) r1 = await geo_cache.lookup("192.0.2.1", session) r2 = await geo_cache.lookup("192.0.2.1", session) # Only one HTTP call should have been made; second served from neg cache. assert session.get.call_count == 1 assert r1 is not None and r1.country_code is None assert r2 is not None and r2.country_code is None async def test_neg_cache_retries_after_ttl(self, geo_cache: GeoCache) -> None: """When the neg-cache entry is older than the TTL a new API call is made.""" session = _make_session({"status": "fail", "message": "private range"}) await geo_cache.lookup("192.0.2.2", session) # Manually expire the neg-cache entry. geo_cache._neg_cache["192.0.2.2"] -= _NEG_CACHE_TTL + 1 await geo_cache.lookup("192.0.2.2", session) # Both calls should have hit the API. assert session.get.call_count == 2 async def test_clear_neg_cache_allows_immediate_retry(self, geo_cache: GeoCache) -> None: """After clearing the neg cache the IP is eligible for a new API call.""" session = _make_session({"status": "fail", "message": "private range"}) await geo_cache.lookup("192.0.2.3", session) await geo_cache.clear_neg_cache() await geo_cache.lookup("192.0.2.3", session) assert session.get.call_count == 2 async def test_successful_lookup_does_not_pollute_neg_cache(self, geo_cache: GeoCache) -> None: """A successful lookup must not create a neg-cache entry.""" session = _make_session( { "status": "success", "countryCode": "DE", "country": "Germany", "as": "AS3320", "org": "Telekom", } ) await geo_cache.lookup("1.2.3.4", session) assert "1.2.3.4" not in geo_cache._neg_cache # --------------------------------------------------------------------------- # GeoIP2 (MaxMind) fallback # --------------------------------------------------------------------------- class TestGeoipFallback: """Verify the MaxMind GeoLite2 is used as the primary resolver. With the new implementation, MMDB (MaxMind GeoLite2-Country) is the primary resolver, tried first before the HTTP API. HTTP is only used if MMDB is unavailable or returns no result (and allow_http_fallback is enabled). """ def _make_geoip_reader(self, iso_code: str, name: str) -> MagicMock: """Build a mock geoip2.database.Reader that returns *iso_code*.""" country_mock = MagicMock() country_mock.iso_code = iso_code country_mock.name = name response_mock = MagicMock() response_mock.country = country_mock reader = MagicMock() reader.country = MagicMock(return_value=response_mock) return reader async def test_geoip_primary_resolver_success(self, geo_cache: GeoCache) -> None: """MMDB is used as the primary resolver and HTTP API is skipped on success.""" session = _make_session( { "status": "success", "countryCode": "JP", "country": "Japan", "as": "AS12345", "org": "NTT", } ) mock_reader = self._make_geoip_reader("DE", "Germany") with patch.object(geo_cache, "_geoip_reader", mock_reader): result = await geo_cache.lookup("1.2.3.4", session) # MMDB should be called (primary resolver). mock_reader.country.assert_called_once_with("1.2.3.4") # HTTP API should NOT be called since MMDB succeeded. session.get.assert_not_called() # Result should be from MMDB, not HTTP. assert result is not None assert result.country_code == "DE" assert result.country_name == "Germany" async def test_geoip_fallback_when_http_fails(self, geo_cache: GeoCache) -> None: """When HTTP API fails, MMDB is used (it's already tried first).""" session = _make_session({"status": "fail", "message": "reserved range"}) mock_reader = self._make_geoip_reader("DE", "Germany") with patch.object(geo_cache, "_geoip_reader", mock_reader): result = await geo_cache.lookup("1.2.3.4", session) mock_reader.country.assert_called_once_with("1.2.3.4") assert result is not None assert result.country_code == "DE" assert result.country_name == "Germany" async def test_geoip_fallback_result_stored_in_cache(self, geo_cache: GeoCache) -> None: """A successful geoip2 result is stored in the positive cache.""" session = _make_session({"status": "fail", "message": "reserved range"}) mock_reader = self._make_geoip_reader("US", "United States") with patch.object(geo_cache, "_geoip_reader", mock_reader): await geo_cache.lookup("8.8.8.8", session) # Second call must be served from positive cache without hitting API or MMDB. await geo_cache.lookup("8.8.8.8", session) # MMDB should only be called once (not on second call due to cache). assert mock_reader.country.call_count == 1 # HTTP API should never be called since MMDB succeeded. assert session.get.call_count == 0 assert "8.8.8.8" in geo_cache._cache async def test_geoip_fallback_not_called_when_no_reader(self, geo_cache: GeoCache) -> None: """When no geoip2 reader is configured, resolution falls back to HTTP (if enabled).""" session = _make_session({"status": "fail", "message": "private range"}) with patch.object(geo_cache, "_geoip_reader", None): result = await geo_cache.lookup("10.0.0.1", session) assert result is not None # With HTTP fallback enabled but no result from HTTP, country_code is None. assert result.country_code is None # --------------------------------------------------------------------------- # Batch single-commit behaviour (Task 1) # --------------------------------------------------------------------------- def _make_batch_session(batch_response: Sequence[Mapping[str, object]]) -> MagicMock: """Build a mock aiohttp.ClientSession for batch POST calls. Args: batch_response: The list that the mock response's ``json()`` returns. Returns: A :class:`MagicMock` with a ``post`` method wired as an async context. """ mock_resp = AsyncMock() mock_resp.status = 200 mock_resp.json = AsyncMock(return_value=batch_response) mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_resp) mock_ctx.__aexit__ = AsyncMock(return_value=False) session = MagicMock() session.post = MagicMock(return_value=mock_ctx) return session def _make_async_db() -> MagicMock: """Build a minimal mock :class:`aiosqlite.Connection`. Returns: MagicMock with ``execute``, ``executemany``, ``commit``, and ``rollback`` wired as async coroutines. ``execute`` is an async function that returns an async context manager yielding a cursor. """ db = MagicMock() # Build a mock cursor for count_unresolved and similar queries. mock_cursor = MagicMock() mock_cursor.fetchone = AsyncMock(return_value=(0,)) mock_cursor.fetchall = AsyncMock(return_value=[]) # Build an async context manager wrapping the cursor. mock_ctx = MagicMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_cursor) mock_ctx.__aexit__ = AsyncMock(return_value=None) # For BEGIN statements (transaction start), return a no-op ctx manager. class _BeginCtx: async def __aenter__(self) -> None: return None async def __aexit__(self, *args: object) -> None: return None async def fake_execute(sql: str, *args: object, **kwargs: object) -> MagicMock: # type: ignore[no-untyped-def] if isinstance(sql, str) and sql.startswith("BEGIN"): return MagicMock(__aenter__=AsyncMock(return_value=None), __aexit__=AsyncMock(return_value=None)) return mock_ctx db.execute = MagicMock(side_effect=fake_execute) db.executemany = AsyncMock() db.commit = AsyncMock() db.rollback = AsyncMock() return db class TestLookupBatchSingleCommit: """lookup_batch() issues exactly one commit per call, not one per IP.""" async def test_single_commit_for_multiple_ips(self, geo_cache: GeoCache) -> None: """A batch of N IPs produces exactly one db.commit(), not N.""" ips = ["1.1.1.1", "2.2.2.2", "3.3.3.3"] batch_response = [ {"query": ip, "status": "success", "countryCode": "DE", "country": "Germany", "as": "AS1", "org": "Org"} for ip in ips ] session = _make_batch_session(batch_response) db = _make_async_db() await geo_cache.lookup_batch(ips, session, db=db) db.commit.assert_awaited_once() async def test_commit_called_even_on_failed_lookups(self, geo_cache: GeoCache) -> None: """A batch with all-failed lookups still triggers one commit.""" ips = ["10.0.0.1", "10.0.0.2"] batch_response = [ {"query": ip, "status": "fail", "message": "private range"} for ip in ips ] session = _make_batch_session(batch_response) db = _make_async_db() await geo_cache.lookup_batch(ips, session, db=db) db.commit.assert_awaited_once() async def test_no_commit_when_db_is_none(self, geo_cache: GeoCache) -> None: """When db=None, no commit is attempted.""" ips = ["1.1.1.1"] batch_response = [ { "query": "1.1.1.1", "status": "success", "countryCode": "US", "country": "United States", "as": "AS15169", "org": "Google LLC", }, ] session = _make_batch_session(batch_response) # Should not raise; without db there is nothing to commit. result = await geo_cache.lookup_batch(ips, session, db=None) assert result["1.1.1.1"].country_code == "US" async def test_no_commit_for_all_cached_ips(self, geo_cache: GeoCache) -> None: """When all IPs are already cached, no HTTP call and no commit occur.""" geo_cache._cache["5.5.5.5"] = GeoInfo( country_code="FR", country_name="France", asn="AS1", org="ISP" ) db = _make_async_db() session = _make_batch_session([]) result = await geo_cache.lookup_batch(["5.5.5.5"], session, db=db) assert result["5.5.5.5"].country_code == "FR" db.commit.assert_not_awaited() session.post.assert_not_called() # --------------------------------------------------------------------------- # Dirty-set tracking and flush_dirty (Task 3) # --------------------------------------------------------------------------- class TestDirtySetTracking: """_store() marks successfully resolved IPs as dirty.""" async def test_successful_resolution_adds_to_dirty(self, geo_cache: GeoCache) -> None: """Storing a GeoInfo with a country_code adds the IP to _dirty.""" info = GeoInfo(country_code="DE", country_name="Germany", asn="AS1", org="ISP") await geo_cache._store("1.2.3.4", info) assert "1.2.3.4" in geo_cache._dirty async def test_null_country_does_not_add_to_dirty(self, geo_cache: GeoCache) -> None: """Storing a GeoInfo with country_code=None must not pollute _dirty.""" info = GeoInfo(country_code=None, country_name=None, asn=None, org=None) await geo_cache._store("10.0.0.1", info) assert "10.0.0.1" not in geo_cache._dirty async def test_clear_cache_also_clears_dirty(self, geo_cache: GeoCache) -> None: """clear_cache() must discard any pending dirty entries.""" info = GeoInfo(country_code="US", country_name="United States", asn="AS1", org="ISP") await geo_cache._store("8.8.8.8", info) assert geo_cache._dirty await geo_cache.clear() assert not geo_cache._dirty async def test_lookup_batch_populates_dirty(self, geo_cache: GeoCache) -> None: """After lookup_batch() with db=None, resolved IPs appear in _dirty.""" ips = ["1.1.1.1", "2.2.2.2"] batch_response = [ {"query": ip, "status": "success", "countryCode": "JP", "country": "Japan", "as": "AS7500", "org": "IIJ"} for ip in ips ] session = _make_batch_session(batch_response) await geo_cache.lookup_batch(ips, session, db=None) for ip in ips: assert ip in geo_cache._dirty class TestFlushDirty: """flush_dirty() persists dirty entries and clears the set.""" async def test_flush_writes_and_clears_dirty(self, geo_cache: GeoCache) -> None: """flush_dirty() inserts all dirty IPs and clears _dirty afterwards.""" info = GeoInfo(country_code="GB", country_name="United Kingdom", asn="AS2856", org="BT") await geo_cache._store("100.0.0.1", info) assert "100.0.0.1" in geo_cache._dirty db = _make_async_db() count = await geo_cache.flush_dirty(db) assert count == 1 db.executemany.assert_awaited_once() db.commit.assert_awaited_once() assert "100.0.0.1" not in geo_cache._dirty async def test_flush_returns_zero_when_nothing_dirty(self, geo_cache: GeoCache) -> None: """flush_dirty() returns 0 and makes no DB calls when _dirty is empty.""" db = _make_async_db() count = await geo_cache.flush_dirty(db) assert count == 0 db.executemany.assert_not_awaited() db.commit.assert_not_awaited() async def test_flush_re_adds_to_dirty_on_db_error(self, geo_cache: GeoCache) -> None: """When the DB write fails, entries are re-added to _dirty for retry.""" info = GeoInfo(country_code="AU", country_name="Australia", asn="AS1", org="ISP") await geo_cache._store("200.0.0.1", info) db = _make_async_db() db.executemany = AsyncMock(side_effect=OSError("disk full")) count = await geo_cache.flush_dirty(db) assert count == 0 assert "200.0.0.1" in geo_cache._dirty async def test_flush_batch_and_lookup_batch_integration(self, geo_cache: GeoCache) -> None: """lookup_batch() populates _dirty; flush_dirty() then persists them.""" ips = ["10.1.2.3", "10.1.2.4"] batch_response = [ {"query": ip, "status": "success", "countryCode": "CA", "country": "Canada", "as": "AS812", "org": "Bell"} for ip in ips ] session = _make_batch_session(batch_response) # Resolve without DB to populate only in-memory cache and _dirty. await geo_cache.lookup_batch(ips, session, db=None) assert geo_cache._dirty == set(ips) # Now flush to the DB. db = _make_async_db() count = await geo_cache.flush_dirty(db) assert count == 2 assert not geo_cache._dirty db.commit.assert_awaited_once() # --------------------------------------------------------------------------- # Rate-limit throttling and retry tests (Task 5) # --------------------------------------------------------------------------- class TestLookupBatchThrottling: """Verify the inter-batch delay, retry, and give-up behaviour.""" async def test_lookup_batch_throttles_between_chunks(self, geo_cache: GeoCache) -> None: """When more than _BATCH_SIZE IPs are sent, asyncio.sleep is called between consecutive batch HTTP calls with at least _BATCH_DELAY.""" # Generate _BATCH_SIZE + 1 IPs so we get exactly 2 batch calls. batch_size: int = _BATCH_SIZE ips = [f"10.0.{i // 256}.{i % 256}" for i in range(batch_size + 1)] def _make_result(chunk: list[str], _session: object) -> dict[str, GeoInfo]: return { ip: GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None) for ip in chunk } with ( patch.object( geo_cache, "_batch_api_call", new_callable=AsyncMock, side_effect=_make_result, ) as mock_batch, patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, ): await geo_cache.lookup_batch(ips, MagicMock()) # Two chunks → one sleep between them. assert mock_batch.call_count == 2 mock_sleep.assert_awaited_once() delay_arg: float = mock_sleep.call_args[0][0] assert delay_arg >= _BATCH_DELAY async def test_lookup_batch_retries_on_full_chunk_failure(self, geo_cache: GeoCache) -> None: """When a chunk returns all-None on first try, it retries and succeeds.""" ips = ["1.2.3.4", "5.6.7.8"] _empty = GeoInfo(country_code=None, country_name=None, asn=None, org=None) _success = { "1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None), "5.6.7.8": GeoInfo(country_code="US", country_name="United States", asn=None, org=None), } _failure: dict[str, GeoInfo] = dict.fromkeys(ips, _empty) call_count = 0 async def _side_effect(chunk: list[str], _session: object) -> dict[str, GeoInfo]: nonlocal call_count call_count += 1 if call_count == 1: return _failure return _success with ( patch.object( geo_cache, "_batch_api_call", new_callable=AsyncMock, side_effect=_side_effect, ), patch("asyncio.sleep", new_callable=AsyncMock), ): result = await geo_cache.lookup_batch(ips, MagicMock()) assert call_count == 2 assert result["1.2.3.4"].country_code == "DE" assert result["5.6.7.8"].country_code == "US" async def test_lookup_batch_gives_up_after_max_retries(self, geo_cache: GeoCache) -> None: """After _BATCH_MAX_RETRIES + 1 attempts, IPs end up in the neg cache.""" ips = ["9.9.9.9"] _empty = GeoInfo(country_code=None, country_name=None, asn=None, org=None) _failure: dict[str, GeoInfo] = dict.fromkeys(ips, _empty) max_retries: int = _BATCH_MAX_RETRIES with ( patch.object( geo_cache, "_batch_api_call", new_callable=AsyncMock, return_value=_failure, ) as mock_batch, patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, ): result = await geo_cache.lookup_batch(ips, MagicMock()) # Initial attempt + max_retries retries. assert mock_batch.call_count == max_retries + 1 # IP should have no country. assert result["9.9.9.9"].country_code is None # Negative cache should contain the IP. assert "9.9.9.9" in geo_cache._neg_cache # Sleep called for each retry with exponential backoff. assert mock_sleep.call_count == max_retries backoff_values = [call.args[0] for call in mock_sleep.call_args_list] batch_delay: float = _BATCH_DELAY for i, val in enumerate(backoff_values): expected = batch_delay * (2 ** (i + 1)) assert val == pytest.approx(expected) # --------------------------------------------------------------------------- # Error logging improvements (Task 2) # --------------------------------------------------------------------------- class TestErrorLogging: """Verify that exception details are properly captured in log events. Previously ``str(exc)`` was used which yields an empty string for aiohttp exceptions such as ``ServerDisconnectedError`` that carry no message. The fix uses ``repr(exc)`` so the exception class name is always present, and adds an ``exc_type`` field for easy log filtering. """ async def test_empty_message_exception_logs_exc_type(self, geo_cache: GeoCache) -> None: """When HTTP exception str() is empty, exc_type and repr are still logged.""" class _EmptyMessageError(Exception): """Exception whose str() representation is empty.""" def __str__(self) -> str: return "" session = MagicMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(side_effect=_EmptyMessageError()) mock_ctx.__aexit__ = AsyncMock(return_value=False) session.get = MagicMock(return_value=mock_ctx) import structlog.testing with structlog.testing.capture_logs() as captured, patch.object( geo_cache, "_geoip_reader", None ): # Ensure MMDB is not available so HTTP is tried. result = await geo_cache.lookup("197.221.98.153", session) assert result is not None assert result.country_code is None request_failed = [e for e in captured if e.get("event") == "geo_lookup_http_request_failed"] assert len(request_failed) == 1 event = request_failed[0] # exc_type must name the exception class — never empty. assert event["exc_type"] == "_EmptyMessageError" # repr() must include the class name even when str() is empty. assert "_EmptyMessageError" in event["error"] async def test_connection_error_logs_exc_type(self, geo_cache: GeoCache) -> None: """A standard OSError with message is logged in HTTP request failure log.""" session = MagicMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(side_effect=OSError("connection refused")) mock_ctx.__aexit__ = AsyncMock(return_value=False) session.get = MagicMock(return_value=mock_ctx) import structlog.testing with structlog.testing.capture_logs() as captured, patch.object( geo_cache, "_geoip_reader", None ): # Ensure MMDB is not available so HTTP is tried. await geo_cache.lookup("10.0.0.1", session) request_failed = [e for e in captured if e.get("event") == "geo_lookup_http_request_failed"] assert len(request_failed) == 1 event = request_failed[0] assert event["exc_type"] == "OSError" assert "connection refused" in event["error"] async def test_batch_empty_message_exception_logs_exc_type(self, geo_cache: GeoCache) -> None: """Batch API call: empty-message exceptions include exc_type in the log.""" class _EmptyMessageError(Exception): def __str__(self) -> str: return "" session = MagicMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(side_effect=_EmptyMessageError()) mock_ctx.__aexit__ = AsyncMock(return_value=False) session.post = MagicMock(return_value=mock_ctx) import structlog.testing with structlog.testing.capture_logs() as captured: result = await geo_cache._batch_api_call(["1.2.3.4"], session) assert result["1.2.3.4"].country_code is None batch_failed = [e for e in captured if e.get("event") == "geo_batch_request_failed"] assert len(batch_failed) == 1 event = batch_failed[0] assert event["exc_type"] == "_EmptyMessageError" assert "_EmptyMessageError" in event["error"] # --------------------------------------------------------------------------- # lookup_cached_only (Task 3) # --------------------------------------------------------------------------- class TestLookupCachedOnly: """lookup_cached_only() returns cache hits without making API calls.""" def test_returns_cached_ips(self, geo_cache: GeoCache) -> None: """IPs already in the cache are returned in the geo_map.""" geo_cache._cache["1.1.1.1"] = GeoInfo( country_code="AU", country_name="Australia", asn="AS13335", org="Cloudflare" ) geo_map, uncached = geo_cache.lookup_cached_only(["1.1.1.1"]) assert "1.1.1.1" in geo_map assert geo_map["1.1.1.1"].country_code == "AU" assert uncached == [] def test_returns_uncached_ips(self, geo_cache: GeoCache) -> None: """IPs not in the cache appear in the uncached list.""" geo_map, uncached = geo_cache.lookup_cached_only(["9.9.9.9"]) assert "9.9.9.9" not in geo_map assert "9.9.9.9" in uncached def test_neg_cached_ips_excluded_from_uncached(self, geo_cache: GeoCache) -> None: """IPs in the negative cache within TTL are not re-queued as uncached.""" import time geo_cache._neg_cache["10.0.0.1"] = time.monotonic() geo_map, uncached = geo_cache.lookup_cached_only(["10.0.0.1"]) assert "10.0.0.1" not in geo_map assert "10.0.0.1" not in uncached def test_expired_neg_cache_requeued(self, geo_cache: GeoCache) -> None: """IPs whose neg-cache entry has expired are listed as uncached.""" import time # Set neg_cache entry to a time that is definitely expired (300s TTL + 1s margin) geo_cache._neg_cache["10.0.0.2"] = time.monotonic() - 301.0 _geo_map, uncached = geo_cache.lookup_cached_only(["10.0.0.2"]) assert "10.0.0.2" in uncached def test_mixed_ips(self, geo_cache: GeoCache) -> None: """A mix of cached, neg-cached, and unknown IPs is split correctly.""" geo_cache._cache["1.2.3.4"] = GeoInfo( country_code="DE", country_name="Germany", asn=None, org=None ) import time geo_cache._neg_cache["5.5.5.5"] = time.monotonic() geo_map, uncached = geo_cache.lookup_cached_only(["1.2.3.4", "5.5.5.5", "9.9.9.9"]) assert list(geo_map.keys()) == ["1.2.3.4"] assert uncached == ["9.9.9.9"] def test_deduplication(self, geo_cache: GeoCache) -> None: """Duplicate IPs in the input appear at most once in the output.""" geo_cache._cache["1.2.3.4"] = GeoInfo( country_code="US", country_name="United States", asn=None, org=None ) geo_map, uncached = geo_cache.lookup_cached_only( ["9.9.9.9", "9.9.9.9", "1.2.3.4", "1.2.3.4"] ) assert len([ip for ip in geo_map if ip == "1.2.3.4"]) == 1 assert uncached.count("9.9.9.9") == 1 class TestReResolveAll: """Tests for :func:`~app.services.geo_cache.re_resolve_all`.""" async def test_returns_zero_when_no_unresolved_ips(self, geo_cache: GeoCache) -> None: """The service returns zero counts when there are no unresolved IPs.""" db = MagicMock() session = MagicMock() with patch( "app.repositories.geo_cache_repo.get_unresolved_ips", AsyncMock(return_value=[]), ), patch.object( geo_cache, "lookup_batch", AsyncMock(), ) as mock_lookup, patch.object( geo_cache, "clear_neg_cache", AsyncMock(), ) as mock_clear: result = await geo_cache.re_resolve_all(db, session) assert result == {"resolved": 0, "total": 0} mock_clear.assert_not_called() mock_lookup.assert_not_called() async def test_clears_neg_cache_and_returns_counts(self, geo_cache: GeoCache) -> None: """The service clears negative cache and returns resolved and total counts.""" db = MagicMock() session = MagicMock() ips = ["1.1.1.1", "2.2.2.2"] geo_map = { "1.1.1.1": GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None), "2.2.2.2": GeoInfo(country_code=None, country_name=None, asn=None, org=None), } with patch( "app.repositories.geo_cache_repo.get_unresolved_ips", AsyncMock(return_value=ips), ), patch.object( geo_cache, "lookup_batch", AsyncMock(return_value=geo_map), ) as mock_lookup, patch.object( geo_cache, "clear_neg_cache", AsyncMock(), ) as mock_clear: result = await geo_cache.re_resolve_all(db, session) assert result == {"resolved": 1, "total": 2} mock_clear.assert_called_once() mock_lookup.assert_awaited_once_with(ips, session, db=db) # --------------------------------------------------------------------------- # Bulk DB writes via executemany (Task 3) # --------------------------------------------------------------------------- class TestLookupBatchBulkWrites: """lookup_batch() uses executemany for bulk DB writes, not per-IP execute.""" async def test_executemany_called_for_successful_ips(self, geo_cache: GeoCache) -> None: """When multiple IPs resolve successfully, a single executemany write occurs.""" ips = ["1.1.1.1", "2.2.2.2", "3.3.3.3"] batch_response = [ { "query": ip, "status": "success", "countryCode": "DE", "country": "Germany", "as": "AS3320", "org": "Telekom", } for ip in ips ] session = _make_batch_session(batch_response) db = _make_async_db() await geo_cache.lookup_batch(ips, session, db=db) # One executemany for the positive rows. assert db.executemany.await_count >= 1 # High-level: execute() must NOT be called for the batch writes. db.execute.assert_not_awaited() async def test_executemany_called_for_failed_ips(self, geo_cache: GeoCache) -> None: """When IPs fail resolution, a single executemany write covers neg entries.""" ips = ["10.0.0.1", "10.0.0.2"] batch_response = [ {"query": ip, "status": "fail", "message": "private range"} for ip in ips ] session = _make_batch_session(batch_response) db = _make_async_db() await geo_cache.lookup_batch(ips, session, db=db) assert db.executemany.await_count >= 1 db.execute.assert_not_awaited() async def test_mixed_results_two_executemany_calls(self, geo_cache: GeoCache) -> None: """A mix of successful and failed IPs produces two executemany calls.""" ips = ["1.1.1.1", "10.0.0.1"] batch_response = [ { "query": "1.1.1.1", "status": "success", "countryCode": "AU", "country": "Australia", "as": "AS13335", "org": "Cloudflare", }, {"query": "10.0.0.1", "status": "fail", "message": "private range"}, ] session = _make_batch_session(batch_response) db = _make_async_db() await geo_cache.lookup_batch(ips, session, db=db) # One executemany for positives, one for negatives. assert db.executemany.await_count == 2 db.execute.assert_not_awaited() # --------------------------------------------------------------------------- # Cache metrics (Issue #15) # --------------------------------------------------------------------------- class TestCacheMetrics: """Metrics counters track cache hit/miss ratios.""" async def test_cache_hit_increments_hits(self) -> None: """lookup() with a cached IP increments _hits.""" geo_cache = GeoCache(allow_http_fallback=True) geo_cache._cache["1.1.1.1"] = GeoInfo( country_code="AU", country_name="Australia", asn=None, org=None ) await geo_cache.lookup("1.1.1.1", MagicMock()) assert geo_cache._hits == 1 assert geo_cache._misses == 0 async def test_cache_miss_increments_misses(self) -> None: """lookup() with allow_http_fallback=False increments _misses on MMDB miss.""" geo_cache = GeoCache(allow_http_fallback=False) await geo_cache.lookup("10.255.255.1", MagicMock()) assert geo_cache._hits == 0 assert geo_cache._misses == 1 async def test_batch_hits_count_cached_ips(self) -> None: """lookup_batch increments _hits for IPs already in cache.""" geo_cache = GeoCache(allow_http_fallback=True) session = _make_batch_session( [ { "query": "1.1.1.1", "status": "success", "countryCode": "AU", "country": "Australia", "as": "AS13335", "org": "Cloudflare", }, ] ) # First: populate cache for 1.1.1.1. await geo_cache.lookup_batch(["1.1.1.1"], session) # Second call: 1.1.1.1 is cache hit; 2.2.2.2 is not cached → HTTP call. session2 = _make_batch_session( [ { "query": "2.2.2.2", "status": "success", "countryCode": "BR", "country": "Brazil", "as": "AS1", "org": "Org", }, ] ) await geo_cache.lookup_batch(["1.1.1.1", "2.2.2.2"], session2) assert geo_cache._hits == 1, f"Expected 1 hit, got {geo_cache._hits}" assert geo_cache._misses == 2, f"Expected 2 misses (both IPs needed resolution), got {geo_cache._misses}" async def test_cache_stats_includes_hits_and_misses(self) -> None: """cache_stats() returns hits and misses counters.""" geo_cache = GeoCache() db = MagicMock() # Patch count_unresolved to avoid the async context manager issue. from app.repositories import geo_cache_repo with patch.object( geo_cache_repo, "count_unresolved", AsyncMock(return_value=0), ): stats = await geo_cache.cache_stats(db) assert "hits" in stats assert "misses" in stats assert stats["hits"] == geo_cache._hits assert stats["misses"] == geo_cache._misses async def test_clear_resets_hits_and_misses(self) -> None: """clear() resets _hits and _misses to zero.""" geo_cache = GeoCache() geo_cache._hits = 42 geo_cache._misses = 99 await geo_cache.clear() assert geo_cache._hits == 0 assert geo_cache._misses == 0 # --------------------------------------------------------------------------- # Pre-warming (Issue #15) # --------------------------------------------------------------------------- class TestPrewarm: """prewarm() loads geo data without blocking the caller.""" async def test_prewarm_fires_and_forgets(self) -> None: """prewarm() returns None immediately (fire-and-forget).""" geo_cache = GeoCache(allow_http_fallback=True) session = _make_batch_session( [ { "query": "1.1.1.1", "status": "success", "countryCode": "AU", "country": "Australia", "as": "AS13335", "org": "Cloudflare", }, ] ) result = geo_cache.prewarm(["1.1.1.1"], session) assert result is None # Let the fire-and-forget task complete. await asyncio.sleep(0.05) async def test_prewarm_populates_cache_eventually(self) -> None: """prewarm() eventually populates the cache via lookup_batch.""" geo_cache = GeoCache(allow_http_fallback=True) session = _make_batch_session( [ { "query": "1.1.1.1", "status": "success", "countryCode": "AU", "country": "Australia", "as": "AS13335", "org": "Cloudflare", }, ] ) geo_cache.prewarm(["1.1.1.1"], session) # Let the fire-and-forget task run. await asyncio.sleep(0.05) assert "1.1.1.1" in geo_cache._cache async def test_prewarm_accepts_empty_list(self) -> None: """prewarm() with an empty list does not raise.""" geo_cache = GeoCache() result = geo_cache.prewarm([], MagicMock()) assert result is None await asyncio.sleep(0.01) # --------------------------------------------------------------------------- # Large ban list performance (Issue #15) # --------------------------------------------------------------------------- class TestLargeBanList: """lookup_batch handles large IP lists without O(n) per-IP overhead.""" async def test_batch_processes_1000_ips_single_db_write(self) -> None: """1000 IPs should result in a single bulk DB write per chunk.""" geo_cache = GeoCache(allow_http_fallback=True) ips = [f"1.1.1.{i % 256}" for i in range(1000)] batch_response = [ { "query": ip, "status": "success", "countryCode": "US", "country": "United States", "as": "AS1", "org": "Org1", } for ip in ips ] session = _make_batch_session(batch_response) db = _make_async_db() await geo_cache.lookup_batch(ips, session, db=db) assert db.executemany.await_count >= 1 async def test_batch_deduplicates_ips(self) -> None: """lookup_batch deduplicates input IPs to avoid redundant work.""" geo_cache = GeoCache(allow_http_fallback=True) ips = ["1.1.1.1", "1.1.1.1", "1.1.1.1"] session = _make_batch_session( [ { "query": "1.1.1.1", "status": "success", "countryCode": "AU", "country": "Australia", "as": "AS13335", "org": "Cloudflare", }, ] ) result = await geo_cache.lookup_batch(ips, session) assert len(result) == 1 assert "1.1.1.1" in result