Intermediate commit
This commit is contained in:
parent
0e9e49df16
commit
3d4cfeff1a
65 changed files with 5507 additions and 333 deletions
134
tests/unit/services/domains/arxiv/test_cache.py
Normal file
134
tests/unit/services/domains/arxiv/test_cache.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import ArxivQueryCacheEntry
|
||||
from app.services.domains.arxiv.cache import (
|
||||
build_query_fingerprint,
|
||||
get_cached_feed,
|
||||
run_with_inflight_dedupe,
|
||||
set_cached_feed,
|
||||
)
|
||||
from app.services.domains.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=timezone.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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue