scholarr/tests/unit/services/domains/arxiv/test_cache.py

133 lines
4 KiB
Python

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)