feat: implement Redis caching for analytics endpoints with fallback to database
Test / test (push) Successful in 15s

This commit is contained in:
k1nq
2025-11-29 09:45:27 +05:00
parent 31d6a05521
commit fbb3116a2d
15 changed files with 671 additions and 13 deletions
+9 -1
View File
@@ -8,10 +8,11 @@ import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.deps import get_db_session
from app.api.deps import get_cache_backend, get_db_session
from app.core.security import password_hasher
from app.main import create_app
from app.models import Base
from tests.utils.fake_redis import InMemoryRedis
@pytest.fixture(autouse=True)
@@ -41,6 +42,7 @@ async def session_factory() -> AsyncGenerator[async_sessionmaker[AsyncSession],
@pytest_asyncio.fixture()
async def client(
session_factory: async_sessionmaker[AsyncSession],
cache_stub: InMemoryRedis,
) -> AsyncGenerator[AsyncClient, None]:
app = create_app()
@@ -54,6 +56,12 @@ async def client(
raise
app.dependency_overrides[get_db_session] = _get_session_override
app.dependency_overrides[get_cache_backend] = lambda: cache_stub
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as test_client:
yield test_client
@pytest.fixture()
def cache_stub() -> InMemoryRedis:
return InMemoryRedis()
+36 -1
View File
@@ -23,6 +23,7 @@ class AnalyticsScenario:
user_id: int
user_email: str
token: str
in_progress_deal_id: int
async def prepare_analytics_scenario(session_factory: async_sessionmaker[AsyncSession]) -> AnalyticsScenario:
@@ -102,6 +103,7 @@ async def prepare_analytics_scenario(session_factory: async_sessionmaker[AsyncSe
user_id=user.id,
user_email=user.email,
token=token,
in_progress_deal_id=next(deal.id for deal in deals if deal.status is DealStatus.IN_PROGRESS),
)
@@ -163,4 +165,37 @@ async def test_deals_funnel_returns_breakdown(
qualification = next(item for item in payload["stages"] if item["stage"] == DealStage.QUALIFICATION.value)
assert qualification["total"] == 1
proposal = next(item for item in payload["stages"] if item["stage"] == DealStage.PROPOSAL.value)
assert proposal["conversion_to_next"] == 100.0
assert proposal["conversion_to_next"] == 100.0
@pytest.mark.asyncio
async def test_deal_update_invalidates_cached_summary(
session_factory: async_sessionmaker[AsyncSession],
client: AsyncClient,
cache_stub,
) -> None:
scenario = await prepare_analytics_scenario(session_factory)
headers = _headers(scenario.token, scenario.organization_id)
first = await client.get(
"/api/v1/analytics/deals/summary?days=30",
headers=headers,
)
assert first.status_code == 200
keys = [key async for key in cache_stub.scan_iter("analytics:summary:*")]
assert keys, "cache should contain warmed summary"
patch_response = await client.patch(
f"/api/v1/deals/{scenario.in_progress_deal_id}",
json={"status": DealStatus.WON.value, "stage": DealStage.CLOSED.value},
headers=headers,
)
assert patch_response.status_code == 200
refreshed = await client.get(
"/api/v1/analytics/deals/summary?days=30",
headers=headers,
)
assert refreshed.status_code == 200
payload = refreshed.json()
assert payload["won"]["count"] == 2
+84 -2
View File
@@ -17,7 +17,8 @@ from app.models.organization import Organization
from app.models.organization_member import OrganizationMember, OrganizationRole
from app.models.user import User
from app.repositories.analytics_repo import AnalyticsRepository
from app.services.analytics_service import AnalyticsService
from app.services.analytics_service import AnalyticsService, invalidate_analytics_cache
from tests.utils.fake_redis import InMemoryRedis
@pytest_asyncio.fixture()
@@ -149,4 +150,85 @@ async def test_funnel_breakdown_contains_stage_conversions(session: AsyncSession
assert proposal.conversion_to_next == 200.0
last_stage = next(item for item in funnel if item.stage == DealStage.CLOSED)
assert last_stage.conversion_to_next is None
assert last_stage.conversion_to_next is None
class _ExplodingRepository(AnalyticsRepository):
async def fetch_status_rollup(self, organization_id: int): # type: ignore[override]
raise AssertionError("cache not used for status rollup")
async def count_new_deals_since(self, organization_id: int, threshold): # type: ignore[override]
raise AssertionError("cache not used for new deal count")
async def fetch_stage_status_rollup(self, organization_id: int): # type: ignore[override]
raise AssertionError("cache not used for funnel rollup")
@pytest.mark.asyncio
async def test_summary_reads_from_cache_when_available(session: AsyncSession) -> None:
org_id, _, _ = await _seed_data(session)
cache = InMemoryRedis()
service = AnalyticsService(
repository=AnalyticsRepository(session),
cache=cache,
ttl_seconds=60,
backoff_ms=0,
)
await service.get_deal_summary(org_id, days=30)
service._repository = _ExplodingRepository(session)
cached = await service.get_deal_summary(org_id, days=30)
assert cached.total_deals == 6
@pytest.mark.asyncio
async def test_invalidation_refreshes_cached_summary(session: AsyncSession) -> None:
org_id, _, contact_id = await _seed_data(session)
cache = InMemoryRedis()
service = AnalyticsService(
repository=AnalyticsRepository(session),
cache=cache,
ttl_seconds=60,
backoff_ms=0,
)
await service.get_deal_summary(org_id, days=30)
deal = Deal(
organization_id=org_id,
contact_id=contact_id,
owner_id=1,
title="New",
amount=Decimal("50"),
status=DealStatus.NEW,
stage=DealStage.QUALIFICATION,
created_at=datetime.now(timezone.utc),
)
session.add(deal)
await session.commit()
cached = await service.get_deal_summary(org_id, days=30)
assert cached.total_deals == 6
await invalidate_analytics_cache(cache, org_id, backoff_ms=0)
refreshed = await service.get_deal_summary(org_id, days=30)
assert refreshed.total_deals == 7
@pytest.mark.asyncio
async def test_funnel_reads_from_cache_when_available(session: AsyncSession) -> None:
org_id, _, _ = await _seed_data(session)
cache = InMemoryRedis()
service = AnalyticsService(
repository=AnalyticsRepository(session),
cache=cache,
ttl_seconds=60,
backoff_ms=0,
)
await service.get_deal_funnel(org_id)
service._repository = _ExplodingRepository(session)
cached = await service.get_deal_funnel(org_id)
assert len(cached) == 4
+57
View File
@@ -0,0 +1,57 @@
"""Simple in-memory Redis replacement for tests."""
from __future__ import annotations
import fnmatch
import time
from collections.abc import AsyncIterator
class InMemoryRedis:
"""Subset of redis.asyncio.Redis API backed by an in-memory dict."""
def __init__(self) -> None:
self._store: dict[str, bytes] = {}
self._expirations: dict[str, float] = {}
async def ping(self) -> bool: # pragma: no cover - compatibility shim
return True
async def get(self, name: str) -> bytes | None:
self._purge_if_expired(name)
return self._store.get(name)
async def set(self, name: str, value: bytes, ex: int | None = None) -> None:
self._store[name] = value
if ex is not None:
self._expirations[name] = time.monotonic() + ex
elif name in self._expirations:
self._expirations.pop(name, None)
async def delete(self, *names: str) -> int:
removed = 0
for name in names:
if name in self._store:
del self._store[name]
removed += 1
self._expirations.pop(name, None)
return removed
async def close(self) -> None: # pragma: no cover - interface completeness
self._store.clear()
self._expirations.clear()
async def scan_iter(self, match: str) -> AsyncIterator[str]:
pattern = match or "*"
for key in list(self._store.keys()):
self._purge_if_expired(key)
for key in self._store.keys():
if fnmatch.fnmatch(key, pattern):
yield key
def _purge_if_expired(self, name: str) -> None:
expires_at = self._expirations.get(name)
if expires_at is None:
return
if expires_at <= time.monotonic():
self._store.pop(name, None)
self._expirations.pop(name, None)