feat: implement Redis caching for analytics endpoints with fallback to database
Test / test (push) Successful in 15s
Test / test (push) Successful in 15s
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user