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