from __future__ import annotations import asyncio import gc from datetime import UTC, datetime, timedelta import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import ArxivQueryCacheEntry from app.services.arxiv.cache import ( build_query_fingerprint, get_cached_feed, run_with_inflight_dedupe, set_cached_feed, ) from app.services.arxiv.types import ArxivEntry, ArxivFeed, ArxivOpenSearchMeta def _sample_feed(arxiv_id: str = "1234.5678") -> ArxivFeed: return ArxivFeed( entries=[ ArxivEntry( entry_id_url=f"https://arxiv.org/abs/{arxiv_id}", arxiv_id=arxiv_id, title="Sample", summary="Summary", published=None, updated=None, ) ], opensearch=ArxivOpenSearchMeta(total_results=1, start_index=0, items_per_page=1), ) def test_build_query_fingerprint_normalizes_search_and_id_params() -> None: first = build_query_fingerprint( params={ "search_query": ' TI:"Quantum Fields" AND AU:"Doe" ', "start": 0, "max_results": 3, } ) second = build_query_fingerprint( params={ "search_query": 'ti:"quantum fields" and au:"doe"', "start": 0, "max_results": 3, } ) third = build_query_fingerprint(params={"id_list": " 2222.0002,1111.0001 "}) fourth = build_query_fingerprint(params={"id_list": "1111.0001, 2222.0002"}) assert first == second assert third == fourth @pytest.mark.asyncio async def test_cache_entry_expires_and_is_deleted(db_session: AsyncSession) -> None: query_fingerprint = build_query_fingerprint(params={"search_query": "ti:test", "start": 0}) now_utc = datetime(2026, 2, 26, 12, 0, tzinfo=UTC) await set_cached_feed( query_fingerprint=query_fingerprint, feed=_sample_feed(), ttl_seconds=5.0, max_entries=16, now_utc=now_utc, ) hit = await get_cached_feed( query_fingerprint=query_fingerprint, now_utc=now_utc + timedelta(seconds=2), ) miss = await get_cached_feed( query_fingerprint=query_fingerprint, now_utc=now_utc + timedelta(seconds=8), ) result = await db_session.execute( select(ArxivQueryCacheEntry).where(ArxivQueryCacheEntry.query_fingerprint == query_fingerprint) ) assert hit is not None assert miss is None assert result.scalar_one_or_none() is None @pytest.mark.asyncio async def test_inflight_dedupe_coalesces_identical_requests() -> None: calls = {"count": 0} async def _fetch_feed() -> ArxivFeed: calls["count"] += 1 await asyncio.sleep(0.05) return _sample_feed("9999.0001") first, second = await asyncio.gather( run_with_inflight_dedupe(query_fingerprint="same-key", fetch_feed=_fetch_feed), run_with_inflight_dedupe(query_fingerprint="same-key", fetch_feed=_fetch_feed), ) assert calls["count"] == 1 assert first.entries[0].arxiv_id == "9999.0001" assert second.entries[0].arxiv_id == "9999.0001" @pytest.mark.asyncio async def test_inflight_owner_failure_without_joiner_has_no_unretrieved_exception() -> None: loop = asyncio.get_running_loop() messages: list[str] = [] previous_handler = loop.get_exception_handler() def _capture_exception(_loop, context) -> None: messages.append(str(context.get("message", ""))) loop.set_exception_handler(_capture_exception) try: async def _failing_fetch() -> ArxivFeed: raise RuntimeError("owner_failed") with pytest.raises(RuntimeError, match="owner_failed"): await run_with_inflight_dedupe( query_fingerprint="owner-failure-no-joiner", fetch_feed=_failing_fetch, ) gc.collect() await asyncio.sleep(0) finally: loop.set_exception_handler(previous_handler) assert "Future exception was never retrieved" not in " | ".join(messages)