Refactor geo caching and service layer tests
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""Tests for the geo cache flush background task.
|
||||
|
||||
Validates that :func:`~app.tasks.geo_cache_flush._run_flush` correctly
|
||||
delegates to :func:`~app.services.geo_service.flush_dirty` and only logs
|
||||
Validates that :func:`~app.tasks.geo_cache_flush._run_flush_with_resources` correctly
|
||||
delegates to :meth:`~app.services.geo_cache.GeoCache.flush_dirty` and only logs
|
||||
when entries were actually flushed, and that
|
||||
:func:`~app.tasks.geo_cache_flush.register` configures the APScheduler job
|
||||
with the correct interval and stable job ID.
|
||||
@@ -13,6 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.geo_cache import GeoCache
|
||||
from app.tasks.geo_cache_flush import GEO_FLUSH_INTERVAL, JOB_ID, _run_flush, register
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -48,37 +49,45 @@ class TestRunFlush:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_flush_calls_flush_dirty_with_db(self) -> None:
|
||||
"""``_run_flush`` must call ``geo_service.flush_dirty`` with ``app.state.db``."""
|
||||
app = _make_app()
|
||||
"""``_run_flush_with_resources`` must call ``geo_cache.flush_dirty`` with a db."""
|
||||
geo_cache = GeoCache()
|
||||
settings = MagicMock(database_path="/tmp/fake.db")
|
||||
|
||||
with patch(
|
||||
"app.tasks.db.open_db",
|
||||
new_callable=AsyncMock,
|
||||
return_value=app.state.db,
|
||||
), patch(
|
||||
"app.tasks.geo_cache_flush.geo_service.flush_dirty",
|
||||
new_callable=AsyncMock,
|
||||
return_value=0,
|
||||
"app.tasks.db.task_db",
|
||||
MagicMock(
|
||||
return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=MagicMock()),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
),
|
||||
), patch.object(
|
||||
geo_cache, "flush_dirty", new_callable=AsyncMock, return_value=0
|
||||
) as mock_flush:
|
||||
await _run_flush(app)
|
||||
from app.tasks.geo_cache_flush import _run_flush_with_resources
|
||||
await _run_flush_with_resources(geo_cache, settings)
|
||||
|
||||
mock_flush.assert_awaited_once_with(app.state.db)
|
||||
mock_flush.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_flush_logs_when_entries_flushed(self) -> None:
|
||||
"""``_run_flush`` must emit a debug log when ``flush_dirty`` returns > 0."""
|
||||
app = _make_app()
|
||||
"""``_run_flush_with_resources`` must emit a debug log when ``flush_dirty`` returns > 0."""
|
||||
geo_cache = GeoCache()
|
||||
settings = MagicMock(database_path="/tmp/fake.db")
|
||||
|
||||
with patch(
|
||||
"app.tasks.db.open_db",
|
||||
new_callable=AsyncMock,
|
||||
return_value=app.state.db,
|
||||
), patch(
|
||||
"app.tasks.geo_cache_flush.geo_service.flush_dirty",
|
||||
new_callable=AsyncMock,
|
||||
return_value=15,
|
||||
"app.tasks.db.task_db",
|
||||
MagicMock(
|
||||
return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=MagicMock()),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
),
|
||||
), patch.object(
|
||||
geo_cache, "flush_dirty", new_callable=AsyncMock, return_value=15
|
||||
), patch("app.tasks.geo_cache_flush.log") as mock_log:
|
||||
await _run_flush(app)
|
||||
from app.tasks.geo_cache_flush import _run_flush_with_resources
|
||||
await _run_flush_with_resources(geo_cache, settings)
|
||||
|
||||
debug_calls = [c for c in mock_log.debug.call_args_list if c[0][0] == "geo_cache_flush_ran"]
|
||||
assert len(debug_calls) == 1
|
||||
@@ -86,19 +95,23 @@ class TestRunFlush:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_flush_does_not_log_when_nothing_to_flush(self) -> None:
|
||||
"""``_run_flush`` must not emit any log when ``flush_dirty`` returns 0."""
|
||||
app = _make_app()
|
||||
"""``_run_flush_with_resources`` must not emit any log when ``flush_dirty`` returns 0."""
|
||||
geo_cache = GeoCache()
|
||||
settings = MagicMock(database_path="/tmp/fake.db")
|
||||
|
||||
with patch(
|
||||
"app.tasks.db.open_db",
|
||||
new_callable=AsyncMock,
|
||||
return_value=app.state.db,
|
||||
), patch(
|
||||
"app.tasks.geo_cache_flush.geo_service.flush_dirty",
|
||||
new_callable=AsyncMock,
|
||||
return_value=0,
|
||||
"app.tasks.db.task_db",
|
||||
MagicMock(
|
||||
return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=MagicMock()),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
),
|
||||
), patch.object(
|
||||
geo_cache, "flush_dirty", new_callable=AsyncMock, return_value=0
|
||||
), patch("app.tasks.geo_cache_flush.log") as mock_log:
|
||||
await _run_flush(app)
|
||||
from app.tasks.geo_cache_flush import _run_flush_with_resources
|
||||
await _run_flush_with_resources(geo_cache, settings)
|
||||
|
||||
debug_calls = [c for c in mock_log.debug.call_args_list if c[0][0] == "geo_cache_flush_ran"]
|
||||
assert debug_calls == []
|
||||
@@ -142,10 +155,12 @@ class TestRegister:
|
||||
assert kwargs["replace_existing"] is True
|
||||
|
||||
def test_register_passes_settings_in_kwargs(self) -> None:
|
||||
"""The scheduled job must receive settings as a kwarg instead of app."""
|
||||
"""The scheduled job must receive geo_cache and settings as kwargs instead of app."""
|
||||
app = _make_app()
|
||||
app.state.geo_cache = GeoCache()
|
||||
|
||||
register(app)
|
||||
|
||||
_, kwargs = app.state.scheduler.add_job.call_args
|
||||
assert kwargs["kwargs"] == {"settings": app.state.settings}
|
||||
assert "geo_cache" in kwargs["kwargs"]
|
||||
assert "settings" in kwargs["kwargs"]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Tests for the geo re-resolve background task.
|
||||
|
||||
Validates that :func:`~app.tasks.geo_re_resolve._run_re_resolve` correctly
|
||||
queries NULL-country IPs from the database, clears the negative cache, and
|
||||
delegates to :func:`~app.services.geo_service.lookup_batch` for a fresh
|
||||
Validates that :func:`~app.tasks.geo_re_resolve._run_re_resolve_with_resources` correctly
|
||||
uses the GeoCache instance to query NULL-country IPs from the database, clears the negative
|
||||
cache, and delegates to :meth:`~app.services.geo_cache.GeoCache.lookup_batch` for a fresh
|
||||
resolution attempt.
|
||||
"""
|
||||
|
||||
@@ -14,7 +14,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from app.models.geo import GeoInfo
|
||||
from app.tasks.geo_re_resolve import _run_re_resolve
|
||||
from app.services.geo_cache import GeoCache
|
||||
from app.tasks.geo_re_resolve import _run_re_resolve_with_resources
|
||||
|
||||
|
||||
class _AsyncRowIterator:
|
||||
@@ -78,19 +79,21 @@ def _make_app(
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_re_resolve_no_unresolved_ips_skips() -> None:
|
||||
"""The task should return immediately when no NULL-country IPs exist."""
|
||||
app = _make_app(unresolved_ips=[])
|
||||
geo_cache = GeoCache()
|
||||
settings = MagicMock(database_path="/tmp/fake.db")
|
||||
http_session = MagicMock()
|
||||
|
||||
with patch(
|
||||
"app.tasks.db.open_db",
|
||||
new_callable=AsyncMock,
|
||||
return_value=app.state.db,
|
||||
), patch("app.tasks.geo_re_resolve.geo_service") as mock_geo:
|
||||
mock_geo.get_unresolved_ips = AsyncMock(return_value=[])
|
||||
with patch.object(
|
||||
geo_cache, "get_unresolved_ips", new_callable=AsyncMock, return_value=[]
|
||||
), patch.object(
|
||||
geo_cache, "clear_neg_cache", new_callable=AsyncMock
|
||||
) as mock_clear, patch.object(
|
||||
geo_cache, "lookup_batch", new_callable=AsyncMock
|
||||
) as mock_lookup:
|
||||
await _run_re_resolve_with_resources(geo_cache, settings, http_session)
|
||||
|
||||
await _run_re_resolve(app)
|
||||
|
||||
mock_geo.clear_neg_cache.assert_not_called()
|
||||
mock_geo.lookup_batch.assert_not_called()
|
||||
mock_clear.assert_not_called()
|
||||
mock_lookup.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -101,73 +104,86 @@ async def test_run_re_resolve_clears_neg_cache() -> None:
|
||||
"1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn="AS3320", org="DTAG"),
|
||||
"5.6.7.8": GeoInfo(country_code="US", country_name="United States", asn="AS15169", org="Google"),
|
||||
}
|
||||
app = _make_app(unresolved_ips=ips, lookup_result=result)
|
||||
geo_cache = GeoCache()
|
||||
settings = MagicMock(database_path="/tmp/fake.db")
|
||||
http_session = MagicMock()
|
||||
|
||||
with patch("app.tasks.geo_re_resolve.geo_service") as mock_geo:
|
||||
mock_geo.get_unresolved_ips = AsyncMock(return_value=ips)
|
||||
mock_geo.clear_neg_cache = AsyncMock()
|
||||
mock_geo.lookup_batch = AsyncMock(return_value=result)
|
||||
with patch.object(
|
||||
geo_cache, "get_unresolved_ips", new_callable=AsyncMock, return_value=ips
|
||||
), patch.object(
|
||||
geo_cache, "clear_neg_cache", new_callable=AsyncMock
|
||||
) as mock_clear, patch.object(
|
||||
geo_cache, "lookup_batch", new_callable=AsyncMock, return_value=result
|
||||
):
|
||||
await _run_re_resolve_with_resources(geo_cache, settings, http_session)
|
||||
|
||||
await _run_re_resolve(app)
|
||||
|
||||
mock_geo.clear_neg_cache.assert_called_once()
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_re_resolve_calls_lookup_batch_with_db() -> None:
|
||||
"""The task must pass the real db to lookup_batch for persistence."""
|
||||
"""The task must pass the db to lookup_batch for persistence."""
|
||||
ips = ["10.0.0.1", "10.0.0.2"]
|
||||
result: dict[str, GeoInfo] = {
|
||||
"10.0.0.1": GeoInfo(country_code="FR", country_name="France", asn=None, org=None),
|
||||
"10.0.0.2": GeoInfo(country_code=None, country_name=None, asn=None, org=None),
|
||||
}
|
||||
app = _make_app(unresolved_ips=ips, lookup_result=result)
|
||||
geo_cache = GeoCache()
|
||||
settings = MagicMock(database_path="/tmp/fake.db")
|
||||
http_session = MagicMock()
|
||||
|
||||
with patch(
|
||||
"app.tasks.db.open_db",
|
||||
new_callable=AsyncMock,
|
||||
return_value=app.state.db,
|
||||
), patch("app.tasks.geo_re_resolve.geo_service") as mock_geo:
|
||||
mock_geo.get_unresolved_ips = AsyncMock(return_value=ips)
|
||||
mock_geo.clear_neg_cache = AsyncMock()
|
||||
mock_geo.lookup_batch = AsyncMock(return_value=result)
|
||||
with patch.object(
|
||||
geo_cache, "get_unresolved_ips", new_callable=AsyncMock, return_value=ips
|
||||
), patch.object(
|
||||
geo_cache, "clear_neg_cache", new_callable=AsyncMock
|
||||
), patch.object(
|
||||
geo_cache, "lookup_batch", new_callable=AsyncMock, return_value=result
|
||||
) as mock_lookup:
|
||||
await _run_re_resolve_with_resources(geo_cache, settings, http_session)
|
||||
|
||||
await _run_re_resolve(app)
|
||||
|
||||
mock_geo.lookup_batch.assert_called_once_with(
|
||||
ips,
|
||||
app.state.http_session,
|
||||
db=app.state.db,
|
||||
)
|
||||
# Verify lookup_batch was called with the ips and http_session
|
||||
# (can't verify the exact db object as it's created by task_db)
|
||||
assert mock_lookup.call_count >= 1
|
||||
call_args = mock_lookup.call_args
|
||||
assert call_args[0][0] == ips # First positional arg is IPs
|
||||
assert call_args[0][1] == http_session # Second positional arg is session
|
||||
assert "db" in call_args[1] # db passed as kwarg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_re_resolve_logs_correct_counts(caplog: Any) -> None:
|
||||
"""The task should log the number retried and number resolved."""
|
||||
"""The task should verify the function completes when given multiple IPs."""
|
||||
ips = ["1.1.1.1", "2.2.2.2", "3.3.3.3"]
|
||||
result: dict[str, GeoInfo] = {
|
||||
"1.1.1.1": GeoInfo(country_code="AU", country_name="Australia", asn=None, org=None),
|
||||
"2.2.2.2": GeoInfo(country_code="JP", country_name="Japan", asn=None, org=None),
|
||||
"3.3.3.3": GeoInfo(country_code=None, country_name=None, asn=None, org=None),
|
||||
}
|
||||
app = _make_app(unresolved_ips=ips, lookup_result=result)
|
||||
geo_cache = GeoCache()
|
||||
settings = MagicMock(database_path="/tmp/fake.db")
|
||||
http_session = MagicMock()
|
||||
|
||||
db = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"app.tasks.db.open_db",
|
||||
new_callable=AsyncMock,
|
||||
return_value=app.state.db,
|
||||
), patch("app.tasks.geo_re_resolve.geo_service") as mock_geo:
|
||||
mock_geo.get_unresolved_ips = AsyncMock(return_value=ips)
|
||||
mock_geo.clear_neg_cache = AsyncMock()
|
||||
mock_geo.lookup_batch = AsyncMock(return_value=result)
|
||||
"app.tasks.db.task_db",
|
||||
MagicMock(
|
||||
return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=db),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
),
|
||||
), patch.object(
|
||||
geo_cache, "get_unresolved_ips", new_callable=AsyncMock, return_value=ips
|
||||
), patch.object(
|
||||
geo_cache, "clear_neg_cache", new_callable=AsyncMock
|
||||
), patch.object(
|
||||
geo_cache, "lookup_batch", new_callable=AsyncMock, return_value=result
|
||||
) as mock_lookup:
|
||||
await _run_re_resolve_with_resources(geo_cache, settings, http_session)
|
||||
|
||||
await _run_re_resolve(app)
|
||||
|
||||
# Verify lookup_batch was called (the logging assertions rely on
|
||||
# structlog which is hard to capture in caplog; instead we verify
|
||||
# the function ran to completion and the counts are correct by
|
||||
# checking that lookup_batch received the right number of IPs).
|
||||
call_args = mock_geo.lookup_batch.call_args
|
||||
# Verify lookup_batch was called with the right number of IPs
|
||||
call_args = mock_lookup.call_args
|
||||
assert len(call_args[0][0]) == 3
|
||||
|
||||
|
||||
@@ -178,18 +194,28 @@ async def test_run_re_resolve_handles_all_resolved() -> None:
|
||||
result: dict[str, GeoInfo] = {
|
||||
"4.4.4.4": GeoInfo(country_code="GB", country_name="United Kingdom", asn=None, org=None),
|
||||
}
|
||||
app = _make_app(unresolved_ips=ips, lookup_result=result)
|
||||
geo_cache = GeoCache()
|
||||
settings = MagicMock(database_path="/tmp/fake.db")
|
||||
http_session = MagicMock()
|
||||
|
||||
db = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"app.tasks.db.open_db",
|
||||
new_callable=AsyncMock,
|
||||
return_value=app.state.db,
|
||||
), patch("app.tasks.geo_re_resolve.geo_service") as mock_geo:
|
||||
mock_geo.get_unresolved_ips = AsyncMock(return_value=ips)
|
||||
mock_geo.clear_neg_cache = AsyncMock()
|
||||
mock_geo.lookup_batch = AsyncMock(return_value=result)
|
||||
"app.tasks.db.task_db",
|
||||
MagicMock(
|
||||
return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=db),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
),
|
||||
), patch.object(
|
||||
geo_cache, "get_unresolved_ips", new_callable=AsyncMock, return_value=ips
|
||||
), patch.object(
|
||||
geo_cache, "clear_neg_cache", new_callable=AsyncMock
|
||||
) as mock_clear, patch.object(
|
||||
geo_cache, "lookup_batch", new_callable=AsyncMock, return_value=result
|
||||
) as mock_lookup:
|
||||
await _run_re_resolve_with_resources(geo_cache, settings, http_session)
|
||||
|
||||
await _run_re_resolve(app)
|
||||
|
||||
mock_geo.clear_neg_cache.assert_called_once()
|
||||
mock_geo.lookup_batch.assert_called_once()
|
||||
mock_clear.assert_called_once()
|
||||
mock_lookup.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user