133 lines
4 KiB
Python
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)
|