"""Tests for cache_service: get, set, invalidate, and cleanup. Uses mock database sessions to test cache logic in isolation without requiring a real PostgreSQL connection. """ from __future__ import annotations import uuid from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock import pytest sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for cache service tests") from app.services.cache_service import ( # noqa: E402 cleanup_expired, get_cached, invalidate, set_cached, ) # --------------------------------------------------------------------------- # Helpers: mock objects for BlockchainCache entries # --------------------------------------------------------------------------- def _make_cache_entry( key: str = "test:key", value: dict | None = None, expired: bool = False, ) -> MagicMock: """Create a mock BlockchainCache entry.""" entry = MagicMock() entry.id = uuid.uuid4() entry.cache_key = key entry.cache_value = value or {"data": 42} entry.fetched_at = datetime.now(timezone.utc) if expired: entry.expires_at = datetime.now(timezone.utc) - timedelta(hours=1) else: entry.expires_at = datetime.now(timezone.utc) + timedelta(hours=1) return entry def _make_async_db(entry: MagicMock | None = None) -> AsyncMock: """Create a mock async database session. The session's execute() returns a result with scalar_one_or_none() that returns the given entry (or None). """ db = AsyncMock() result = MagicMock() result.scalar_one_or_none.return_value = entry db.execute = AsyncMock(return_value=result) db.commit = AsyncMock() db.add = MagicMock() return db # --------------------------------------------------------------------------- # Tests: get_cached # --------------------------------------------------------------------------- class TestGetCached: """Test cache_service.get_cached.""" @pytest.mark.asyncio async def test_returns_none_for_missing_key(self): """get_cached returns None when no entry exists for the key.""" db = _make_async_db(entry=None) result = await get_cached("nonexistent:key", db) assert result is None @pytest.mark.asyncio async def test_returns_value_for_valid_entry(self): """get_cached returns the cached value when entry exists and is not expired.""" entry = _make_cache_entry(key="blockchain:wot_size", value={"value": 7224}) db = _make_async_db(entry=entry) result = await get_cached("blockchain:wot_size", db) assert result == {"value": 7224} @pytest.mark.asyncio async def test_returns_none_for_expired_entry(self): """get_cached returns None when the entry is expired. Note: In the real implementation, the SQL query filters by expires_at. Here we test that when the DB returns None (as it would for expired), get_cached returns None. """ db = _make_async_db(entry=None) result = await get_cached("expired:key", db) assert result is None # --------------------------------------------------------------------------- # Tests: set_cached + get_cached roundtrip # --------------------------------------------------------------------------- class TestSetCached: """Test cache_service.set_cached.""" @pytest.mark.asyncio async def test_set_cached_creates_new_entry(self): """set_cached creates a new entry when key does not exist.""" db = _make_async_db(entry=None) await set_cached("new:key", {"value": 100}, db, ttl_seconds=3600) db.add.assert_called_once() db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_set_cached_updates_existing_entry(self): """set_cached updates the value when key already exists.""" existing = _make_cache_entry(key="existing:key", value={"value": 50}) db = _make_async_db(entry=existing) await set_cached("existing:key", {"value": 200}, db, ttl_seconds=7200) # Should update in-place, not add new db.add.assert_not_called() assert existing.cache_value == {"value": 200} db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_set_cached_roundtrip(self): """set_cached followed by get_cached returns the stored value.""" # First call: set (no existing entry) db_set = _make_async_db(entry=None) await set_cached("roundtrip:key", {"data": "test"}, db_set, ttl_seconds=3600) db_set.add.assert_called_once() # Extract the added entry from the mock added_entry = db_set.add.call_args[0][0] # Second call: get (returns the added entry) db_get = _make_async_db(entry=added_entry) result = await get_cached("roundtrip:key", db_get) assert result == {"data": "test"} # --------------------------------------------------------------------------- # Tests: invalidate # --------------------------------------------------------------------------- class TestInvalidate: """Test cache_service.invalidate.""" @pytest.mark.asyncio async def test_invalidate_removes_entry(self): """invalidate executes a delete and commits.""" db = AsyncMock() db.execute = AsyncMock() db.commit = AsyncMock() await invalidate("some:key", db) db.execute.assert_awaited_once() db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_invalidate_then_get_returns_none(self): """After invalidation, get_cached returns None.""" db = _make_async_db(entry=None) result = await get_cached("invalidated:key", db) assert result is None # --------------------------------------------------------------------------- # Tests: cleanup_expired # --------------------------------------------------------------------------- class TestCleanupExpired: """Test cache_service.cleanup_expired.""" @pytest.mark.asyncio async def test_cleanup_removes_expired_entries(self): """cleanup_expired executes a delete for expired entries and returns count.""" db = AsyncMock() # Mock the execute result with rowcount exec_result = MagicMock() exec_result.rowcount = 3 db.execute = AsyncMock(return_value=exec_result) db.commit = AsyncMock() count = await cleanup_expired(db) assert count == 3 db.execute.assert_awaited_once() db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_cleanup_with_no_expired_entries(self): """cleanup_expired returns 0 when no expired entries exist.""" db = AsyncMock() exec_result = MagicMock() exec_result.rowcount = 0 db.execute = AsyncMock(return_value=exec_result) db.commit = AsyncMock() count = await cleanup_expired(db) assert count == 0