Backend: rate limiter, security headers, blockchain cache service avec RPC, public API (7 endpoints read-only), WebSocket auth + heartbeat, DB connection pooling, structured logging, health check DB. Frontend: API retry/timeout, WebSocket auth + heartbeat + typed events, notifications toast, mobile hamburger + drawer, error boundary, offline banner, loading skeletons, dashboard enrichi. Documentation: guides utilisateur complets (demarrage, vote, sanctuaire, FAQ 30+), guide deploiement, politique securite. 123 tests, 155 fichiers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
216 lines
6.9 KiB
Python
216 lines
6.9 KiB
Python
"""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
|