diff --git a/backend/main.py b/backend/main.py index 2a522bb..3383bc4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,6 +11,9 @@ from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware from rate_limiter import limiter +from fastapi_cache import FastAPICache +from fastapi_cache.backends.inmemory import InMemoryBackend +from fastapi_cache.decorator import cache from chat_router import router as chat_router @@ -95,6 +98,7 @@ async def lifespan(app: FastAPI): f"WARNING: Model files not found at {MODEL_DIR}. " "Scan endpoints will return 503 until models are present." ) + FastAPICache.init(InMemoryBackend(), prefix="freshscanai-cache") yield @@ -771,31 +775,35 @@ async def get_vendor_leaderboard(): # ── MAP ─────────────────────────────────────────────────────────────────────── +@cache(expire=300, namespace="markets") +async def _get_markets_cached() -> dict: + resp = ( + _db() + .table("vendors") + .select("id, name, avg_freshness_score, trust_score, lat, lng, vendor_count") + .execute() + ) + markets = [ + { + "id": i + 1, + "name": v["name"], + "score": int(v.get("avg_freshness_score") or v.get("trust_score") or 0), + "lat": float(v.get("lat") or 0), + "lng": float(v.get("lng") or 0), + "vendors": int(v.get("vendor_count") or 1), + } + for i, v in enumerate(resp.data or []) + if v.get("lat") and v.get("lng") + ] + return {"success": True, "markets": markets} + + @app.get("/api/v1/maps/markets") @limiter.limit("20/minute") async def get_markets(request: Request): try: - resp = ( - _db() - .table("vendors") - .select("id, name, avg_freshness_score, trust_score, lat, lng, vendor_count") - .execute() - ) - markets = [ - { - "id": i + 1, - "name": v["name"], - "score": int(v.get("avg_freshness_score") or v.get("trust_score") or 0), - "lat": float(v.get("lat") or 0), - "lng": float(v.get("lng") or 0), - "vendors": int(v.get("vendor_count") or 1), - } - for i, v in enumerate(resp.data or []) - if v.get("lat") and v.get("lng") - ] - return {"success": True, "markets": markets} + return await _get_markets_cached() except Exception: - # Migration not applied yet — return empty markets, map still renders return { "success": True, "markets": [], diff --git a/backend/requirements.txt b/backend/requirements.txt index 707747a..94e5795 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,4 +11,6 @@ pytest>=8.0.0 # Comment these out if you don't have GPU/model files and just want demo mode. torch>=2.2.0 torchvision>=0.27.0 -slowapi==0.1.9 # rate limiting for FastAPI; added for per-user scan endpoint throttling \ No newline at end of file +slowapi==0.1.9 # rate limiting for FastAPI; added for per-user scan endpoint throttling + +fastapi-cache2==0.2.2 \ No newline at end of file diff --git a/backend/tests/test_cache.py b/backend/tests/test_cache.py new file mode 100644 index 0000000..b8f6472 --- /dev/null +++ b/backend/tests/test_cache.py @@ -0,0 +1,76 @@ +import asyncio + +import pytest +from fastapi_cache import FastAPICache +from fastapi_cache.backends.inmemory import InMemoryBackend +from fastapi_cache.decorator import cache + + +@pytest.fixture(autouse=True) +def _init_cache(): + FastAPICache.init(InMemoryBackend(), prefix="test-cache") + yield + + +def test_cache_returns_same_value_within_ttl(): + calls = {"count": 0} + + @cache(expire=60, namespace="test_ns_1") + async def handler(): + calls["count"] += 1 + return {"value": calls["count"]} + + first = asyncio.run(handler()) + second = asyncio.run(handler()) + + assert first == {"value": 1} + assert second == {"value": 1} + assert calls["count"] == 1 + + +def test_cache_expires_after_ttl(): + calls = {"count": 0} + + @cache(expire=1, namespace="test_ns_2") + async def handler(): + calls["count"] += 1 + return {"value": calls["count"]} + + asyncio.run(handler()) + asyncio.run(asyncio.sleep(2.1)) + second = asyncio.run(handler()) + + assert second == {"value": 2} + assert calls["count"] == 2 + + +def test_cache_clear_forces_recompute(): + calls = {"count": 0} + + @cache(expire=60, namespace="test_ns_3") + async def handler(): + calls["count"] += 1 + return {"value": calls["count"]} + + asyncio.run(handler()) + asyncio.run(FastAPICache.clear(namespace="test_ns_3")) + second = asyncio.run(handler()) + + assert second == {"value": 2} + assert calls["count"] == 2 + + +def test_exception_inside_cached_function_is_not_cached(): + calls = {"count": 0} + + @cache(expire=60, namespace="test_ns_4") + async def handler(): + calls["count"] += 1 + raise ValueError("boom") + + with pytest.raises(ValueError): + asyncio.run(handler()) + with pytest.raises(ValueError): + asyncio.run(handler()) + + assert calls["count"] == 2 diff --git a/backend/vendors.py b/backend/vendors.py index 4a64c3a..4e506ec 100644 --- a/backend/vendors.py +++ b/backend/vendors.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query from datetime import datetime, timedelta, timezone from auth import get_current_user +from fastapi_cache import FastAPICache router = APIRouter(prefix="/api/v1/vendors", tags=["vendors"]) @@ -128,6 +129,8 @@ async def recalculate_trust_score( } ).eq("id", vendor_id).execute() + await FastAPICache.clear(namespace="markets") + return { "success": True, "vendor_id": vendor_id,