# tests/test_api.py import json from unittest.mock import AsyncMock, MagicMock import fakeredis import fakeredis.aioredis import pytest from httpx import ASGITransport, AsyncClient TOP10 = [ {"movie_id": i, "title": f"Movie {i}", "genres": "Action", "score": float(10 - i)} for i in range(1, 11) ] @pytest.fixture def fake_redis(): return fakeredis.aioredis.FakeRedis(decode_responses=False) @pytest.fixture def fake_redis_with_recs(): server = fakeredis.FakeServer() sync_r = fakeredis.FakeRedis(server=server, decode_responses=False) async_r = fakeredis.aioredis.FakeRedis(server=server, decode_responses=False) sync_r.set("recs:1", json.dumps(TOP10).encode()) return async_r @pytest.fixture async def client_redis_hit(fake_redis_with_recs): from main import create_app app = create_app() app.state.redis = fake_redis_with_recs app.state.pg_pool = None async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: yield c @pytest.fixture async def client_redis_miss_pg_hit(fake_redis): from main import create_app app = create_app() app.state.redis = fake_redis # empty mock_pool = MagicMock() mock_conn = AsyncMock() mock_conn.fetchrow = AsyncMock(return_value={"recs": json.dumps(TOP10), "updated_at": None}) mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) app.state.pg_pool = mock_pool async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: yield c @pytest.fixture async def client_all_miss(fake_redis): from main import create_app app = create_app() app.state.redis = fake_redis # empty mock_pool = MagicMock() mock_conn = AsyncMock() mock_conn.fetchrow = AsyncMock(return_value=None) mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) app.state.pg_pool = mock_pool async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: yield c async def test_recommend_redis_hit(client_redis_hit): resp = await client_redis_hit.get("/recommend/1") assert resp.status_code == 200 data = resp.json() assert len(data["recs"]) == 10 assert data["source"] == "redis" assert data["recs"][0]["movie_id"] == 1 async def test_recommend_response_schema(client_redis_hit): resp = await client_redis_hit.get("/recommend/1") rec = resp.json()["recs"][0] assert "movie_id" in rec assert "title" in rec assert "genres" in rec assert "score" in rec async def test_recommend_postgres_fallback(client_redis_miss_pg_hit): resp = await client_redis_miss_pg_hit.get("/recommend/1") assert resp.status_code == 200 data = resp.json() assert data["source"] == "postgres" assert len(data["recs"]) == 10 async def test_recommend_als_fallback(client_all_miss): """When Redis and PostgreSQL both miss, return ALS baseline from Redis als_candidates.""" als_cands = [{"movie_id": i, "title": f"M{i}", "genres": "Drama", "als_score": float(i), "score": float(i)} for i in range(1, 11)] await client_all_miss._transport.app.state.redis.set( "als_candidates:1", json.dumps(als_cands).encode() ) resp = await client_all_miss.get("/recommend/1") assert resp.status_code == 200 data = resp.json() assert data["source"] == "als_baseline" @pytest.fixture async def client_cold_start(fake_redis): """No als_candidates — but popular:global is seeded.""" from main import create_app popular = [{"movie_id": i, "title": f"Pop{i}", "genres": "Drama", "score": float(10 - i)} for i in range(1, 11)] await fake_redis.set("popular:global", json.dumps(popular).encode()) app = create_app() app.state.redis = fake_redis app.state.pg_pool = None async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: yield c async def test_recommend_cold_start_popular(client_cold_start): """Unknown user (no als_candidates) gets global popular fallback.""" resp = await client_cold_start.get("/recommend/99999") assert resp.status_code == 200 data = resp.json() assert data["source"] == "cold_start_popular" assert len(data["recs"]) == 10 async def test_health_returns_ok(client_redis_hit): resp = await client_redis_hit.get("/health") assert resp.status_code == 200 body = resp.json() assert body["status"] == "ok" assert "mode" in body def test_get_variant_is_deterministic(): from routers.recommend import get_variant for uid in [1, 42, 100, 9999]: assert get_variant(uid) == get_variant(uid) def test_get_variant_roughly_balanced(): from routers.recommend import get_variant variants = [get_variant(uid) for uid in range(200)] treatment_pct = sum(1 for v in variants if v == "treatment") / 200 assert 0.35 < treatment_pct < 0.65 async def test_simulate_produces_to_kafka(): from main import create_app app = create_app() app.state.redis = fakeredis.aioredis.FakeRedis() app.state.pg_pool = None mock_producer = MagicMock() app.state.kafka_producer = mock_producer async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: resp = await c.post("/simulate", json={ "user_id": 1, "movie_id": 42, "event_type": "click" }) assert resp.status_code == 200 mock_producer.send.assert_called_once()