402 lines
14 KiB
Python
402 lines
14 KiB
Python
"""Tests for the short-TTL search result cache in api/main.py.
|
|
|
|
Covers:
|
|
- _cache_key stability (same inputs → same key)
|
|
- _cache_key uniqueness (different inputs → different keys)
|
|
- cache hit path returns early without scraping (async worker)
|
|
- cache miss path stores result in _search_result_cache
|
|
- refresh=True bypasses cache read (still writes fresh result)
|
|
- TTL expiry: expired entries are not returned as hits
|
|
- _evict_expired_cache removes expired entries
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import queue as _queue
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def _clear_cache():
|
|
"""Reset module-level cache state between tests."""
|
|
import api.main as _main
|
|
_main._search_result_cache.clear()
|
|
_main._last_eviction_ts = 0.0
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_cache():
|
|
"""Ensure each test starts with an empty cache."""
|
|
_clear_cache()
|
|
yield
|
|
_clear_cache()
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path):
|
|
"""TestClient backed by a fresh tmp DB."""
|
|
os.environ["SNIPE_DB"] = str(tmp_path / "snipe.db")
|
|
from api.main import app
|
|
from fastapi.testclient import TestClient
|
|
return TestClient(app, raise_server_exceptions=False)
|
|
|
|
|
|
def _make_mock_listing(listing_id: str = "123456789", seller_id: str = "test_seller"):
|
|
"""Return a MagicMock listing (for use where asdict() is NOT called on it)."""
|
|
m = MagicMock()
|
|
m.platform_listing_id = listing_id
|
|
m.seller_platform_id = seller_id
|
|
m.title = "Test GPU"
|
|
m.price = 100.0
|
|
m.currency = "USD"
|
|
m.condition = "Used"
|
|
m.url = f"https://www.ebay.com/itm/{listing_id}"
|
|
m.photo_urls = []
|
|
m.listing_age_days = 5
|
|
m.buying_format = "fixed_price"
|
|
m.ends_at = None
|
|
m.fetched_at = None
|
|
m.trust_score_id = None
|
|
m.id = 1
|
|
m.category_name = None
|
|
return m
|
|
|
|
|
|
def _make_real_listing(listing_id: str = "123456789", seller_id: str = "test_seller"):
|
|
"""Return a real Listing dataclass instance (for use where asdict() is called)."""
|
|
from app.db.models import Listing
|
|
return Listing(
|
|
platform="ebay",
|
|
platform_listing_id=listing_id,
|
|
title="Test GPU",
|
|
price=100.0,
|
|
currency="USD",
|
|
condition="Used",
|
|
seller_platform_id=seller_id,
|
|
url=f"https://www.ebay.com/itm/{listing_id}",
|
|
photo_urls=[],
|
|
listing_age_days=5,
|
|
buying_format="fixed_price",
|
|
id=None,
|
|
)
|
|
|
|
|
|
# ── _cache_key unit tests ─────────────────────────────────────────────────────
|
|
|
|
def test_cache_key_stable_for_same_inputs():
|
|
"""The same parameter set always produces the same key."""
|
|
from api.main import _cache_key
|
|
k1 = _cache_key("rtx 3080", 400.0, 100.0, 2, "rtx,3080", "all", "mining", "27386")
|
|
k2 = _cache_key("rtx 3080", 400.0, 100.0, 2, "rtx,3080", "all", "mining", "27386")
|
|
assert k1 == k2
|
|
|
|
|
|
def test_cache_key_case_normalised():
|
|
"""Query is normalised to lower-case + stripped before hashing."""
|
|
from api.main import _cache_key
|
|
k1 = _cache_key("RTX 3080", None, None, 1, "", "all", "", "")
|
|
k2 = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
|
|
assert k1 == k2
|
|
|
|
|
|
def test_cache_key_differs_on_query_change():
|
|
"""Different query strings must produce different keys."""
|
|
from api.main import _cache_key
|
|
k1 = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
|
|
k2 = _cache_key("gtx 1080", None, None, 1, "", "all", "", "")
|
|
assert k1 != k2
|
|
|
|
|
|
def test_cache_key_differs_on_price_filter():
|
|
"""Different max_price must produce a different key."""
|
|
from api.main import _cache_key
|
|
k1 = _cache_key("gpu", 400.0, None, 1, "", "all", "", "")
|
|
k2 = _cache_key("gpu", 500.0, None, 1, "", "all", "", "")
|
|
assert k1 != k2
|
|
|
|
|
|
def test_cache_key_differs_on_min_price():
|
|
"""Different min_price must produce a different key."""
|
|
from api.main import _cache_key
|
|
k1 = _cache_key("gpu", None, 50.0, 1, "", "all", "", "")
|
|
k2 = _cache_key("gpu", None, 100.0, 1, "", "all", "", "")
|
|
assert k1 != k2
|
|
|
|
|
|
def test_cache_key_differs_on_pages():
|
|
"""Different page count must produce a different key."""
|
|
from api.main import _cache_key
|
|
k1 = _cache_key("gpu", None, None, 1, "", "all", "", "")
|
|
k2 = _cache_key("gpu", None, None, 2, "", "all", "", "")
|
|
assert k1 != k2
|
|
|
|
|
|
def test_cache_key_differs_on_must_include():
|
|
"""Different must_include terms must produce a different key."""
|
|
from api.main import _cache_key
|
|
k1 = _cache_key("gpu", None, None, 1, "rtx", "all", "", "")
|
|
k2 = _cache_key("gpu", None, None, 1, "gtx", "all", "", "")
|
|
assert k1 != k2
|
|
|
|
|
|
def test_cache_key_differs_on_must_exclude():
|
|
"""Different must_exclude terms must produce a different key."""
|
|
from api.main import _cache_key
|
|
k1 = _cache_key("gpu", None, None, 1, "", "all", "mining", "")
|
|
k2 = _cache_key("gpu", None, None, 1, "", "all", "defective", "")
|
|
assert k1 != k2
|
|
|
|
|
|
def test_cache_key_differs_on_category_id():
|
|
"""Different category_id must produce a different key."""
|
|
from api.main import _cache_key
|
|
k1 = _cache_key("gpu", None, None, 1, "", "all", "", "27386")
|
|
k2 = _cache_key("gpu", None, None, 1, "", "all", "", "12345")
|
|
assert k1 != k2
|
|
|
|
|
|
def test_cache_key_is_16_chars():
|
|
"""Key must be exactly 16 hex characters."""
|
|
from api.main import _cache_key
|
|
k = _cache_key("gpu", None, None, 1, "", "all", "", "")
|
|
assert len(k) == 16
|
|
assert all(c in "0123456789abcdef" for c in k)
|
|
|
|
|
|
# ── TTL / eviction unit tests ─────────────────────────────────────────────────
|
|
|
|
def test_expired_entry_is_not_returned_as_hit():
|
|
"""An entry past its TTL must not be treated as a cache hit."""
|
|
import api.main as _main
|
|
from api.main import _cache_key
|
|
|
|
key = _cache_key("gpu", None, None, 1, "", "all", "", "")
|
|
# Write an already-expired entry.
|
|
_main._search_result_cache[key] = (
|
|
{"listings": [], "market_price": None},
|
|
time.time() - 1.0, # expired 1 second ago
|
|
)
|
|
|
|
cached = _main._search_result_cache.get(key)
|
|
assert cached is not None
|
|
payload, expiry = cached
|
|
# Simulate the hit-check used in main.py
|
|
assert expiry <= time.time(), "Entry should be expired"
|
|
|
|
|
|
def test_evict_expired_cache_removes_stale_entries():
|
|
"""_evict_expired_cache must remove entries whose expiry has passed."""
|
|
import api.main as _main
|
|
from api.main import _cache_key, _evict_expired_cache
|
|
|
|
key_expired = _cache_key("old query", None, None, 1, "", "all", "", "")
|
|
key_valid = _cache_key("new query", None, None, 1, "", "all", "", "")
|
|
|
|
_main._search_result_cache[key_expired] = (
|
|
{"listings": [], "market_price": None},
|
|
time.time() - 10.0, # already expired
|
|
)
|
|
_main._search_result_cache[key_valid] = (
|
|
{"listings": [], "market_price": 99.0},
|
|
time.time() + 300.0, # valid for 5 min
|
|
)
|
|
|
|
# Reset throttle so eviction runs immediately.
|
|
_main._last_eviction_ts = 0.0
|
|
_evict_expired_cache()
|
|
|
|
assert key_expired not in _main._search_result_cache
|
|
assert key_valid in _main._search_result_cache
|
|
|
|
|
|
def test_evict_is_rate_limited():
|
|
"""_evict_expired_cache should skip eviction if called within 60 s."""
|
|
import api.main as _main
|
|
from api.main import _cache_key, _evict_expired_cache
|
|
|
|
key_expired = _cache_key("stale", None, None, 1, "", "all", "", "")
|
|
_main._search_result_cache[key_expired] = (
|
|
{"listings": [], "market_price": None},
|
|
time.time() - 5.0,
|
|
)
|
|
|
|
# Pretend eviction just ran.
|
|
_main._last_eviction_ts = time.time()
|
|
_evict_expired_cache()
|
|
|
|
# Entry should still be present because eviction was throttled.
|
|
assert key_expired in _main._search_result_cache
|
|
|
|
|
|
# ── Integration tests — async endpoint cache hit ──────────────────────────────
|
|
|
|
def test_async_cache_hit_skips_scraper(client, tmp_path):
|
|
"""On a warm cache hit the scraper adapter must not be called."""
|
|
import threading
|
|
import api.main as _main
|
|
from api.main import _cache_key
|
|
|
|
# Pre-seed a valid cache entry.
|
|
key = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
|
|
_main._search_result_cache[key] = (
|
|
{"listings": [], "market_price": 250.0},
|
|
time.time() + 300.0,
|
|
)
|
|
|
|
scraper_called = threading.Event()
|
|
|
|
def _fake_search(query, filters):
|
|
scraper_called.set()
|
|
return []
|
|
|
|
with (
|
|
patch("api.main._make_adapter") as mock_adapter_factory,
|
|
patch("api.main._trigger_scraper_enrichment"),
|
|
patch("api.main.TrustScorer") as mock_scorer_cls,
|
|
patch("api.main.Store") as mock_store_cls,
|
|
):
|
|
mock_adapter = MagicMock()
|
|
mock_adapter.search.side_effect = _fake_search
|
|
mock_adapter.get_completed_sales.return_value = None
|
|
mock_adapter_factory.return_value = mock_adapter
|
|
|
|
mock_scorer = MagicMock()
|
|
mock_scorer.score_batch.return_value = []
|
|
mock_scorer_cls.return_value = mock_scorer
|
|
|
|
mock_store = MagicMock()
|
|
mock_store.get_listings_staged.return_value = {}
|
|
mock_store.refresh_seller_categories.return_value = 0
|
|
mock_store.save_listings.return_value = None
|
|
mock_store.save_trust_scores.return_value = None
|
|
mock_store.get_market_comp.return_value = None
|
|
mock_store.get_seller.return_value = None
|
|
mock_store.get_user_preference.return_value = None
|
|
mock_store_cls.return_value = mock_store
|
|
|
|
resp = client.get("/api/search/async?q=rtx+3080")
|
|
assert resp.status_code == 202
|
|
|
|
# Give the background worker a moment to run.
|
|
scraper_called.wait(timeout=3.0)
|
|
|
|
# Scraper must NOT have been called on a cache hit.
|
|
assert not scraper_called.is_set(), "Scraper was called despite a warm cache hit"
|
|
|
|
|
|
def test_async_cache_miss_stores_result(client, tmp_path):
|
|
"""After a cache miss the result must be stored in _search_result_cache."""
|
|
import threading
|
|
import api.main as _main
|
|
from api.main import _cache_key
|
|
|
|
search_done = threading.Event()
|
|
real_listing = _make_real_listing()
|
|
|
|
def _fake_search(query, filters):
|
|
return [real_listing]
|
|
|
|
with (
|
|
patch("api.main._make_adapter") as mock_adapter_factory,
|
|
patch("api.main._trigger_scraper_enrichment") as mock_enrich,
|
|
patch("api.main.TrustScorer") as mock_scorer_cls,
|
|
patch("api.main.Store") as mock_store_cls,
|
|
):
|
|
mock_adapter = MagicMock()
|
|
mock_adapter.search.side_effect = _fake_search
|
|
mock_adapter.get_completed_sales.return_value = None
|
|
mock_adapter_factory.return_value = mock_adapter
|
|
|
|
mock_scorer = MagicMock()
|
|
mock_scorer.score_batch.return_value = []
|
|
mock_scorer_cls.return_value = mock_scorer
|
|
|
|
mock_store = MagicMock()
|
|
mock_store.get_listings_staged.return_value = {
|
|
real_listing.platform_listing_id: real_listing
|
|
}
|
|
mock_store.refresh_seller_categories.return_value = 0
|
|
mock_store.save_listings.return_value = None
|
|
mock_store.save_trust_scores.return_value = None
|
|
mock_store.get_market_comp.return_value = None
|
|
mock_store.get_seller.return_value = None
|
|
mock_store.get_user_preference.return_value = None
|
|
mock_store_cls.return_value = mock_store
|
|
|
|
def _enrich_side_effect(*args, **kwargs):
|
|
search_done.set()
|
|
|
|
mock_enrich.side_effect = _enrich_side_effect
|
|
|
|
resp = client.get("/api/search/async?q=rtx+3080")
|
|
assert resp.status_code == 202
|
|
|
|
# Wait until the background worker reaches _trigger_scraper_enrichment.
|
|
search_done.wait(timeout=5.0)
|
|
|
|
assert search_done.is_set(), "Background search worker never completed"
|
|
|
|
key = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
|
|
assert key in _main._search_result_cache, "Result was not stored in cache after miss"
|
|
payload, expiry = _main._search_result_cache[key]
|
|
assert expiry > time.time(), "Cache entry has already expired"
|
|
assert "listings" in payload
|
|
|
|
|
|
# ── Integration tests — async endpoint refresh=True ──────────────────────────
|
|
|
|
def test_async_refresh_bypasses_cache_read(client, tmp_path):
|
|
"""refresh=True must bypass cache read and invoke the scraper."""
|
|
import threading
|
|
import api.main as _main
|
|
from api.main import _cache_key
|
|
|
|
# Seed a valid cache entry so we can confirm it is bypassed.
|
|
key = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
|
|
_main._search_result_cache[key] = (
|
|
{"listings": [], "market_price": 100.0},
|
|
time.time() + 300.0,
|
|
)
|
|
|
|
scraper_called = threading.Event()
|
|
|
|
def _fake_search(query, filters):
|
|
scraper_called.set()
|
|
return []
|
|
|
|
with (
|
|
patch("api.main._make_adapter") as mock_adapter_factory,
|
|
patch("api.main._trigger_scraper_enrichment"),
|
|
patch("api.main.TrustScorer") as mock_scorer_cls,
|
|
patch("api.main.Store") as mock_store_cls,
|
|
):
|
|
mock_adapter = MagicMock()
|
|
mock_adapter.search.side_effect = _fake_search
|
|
mock_adapter.get_completed_sales.return_value = None
|
|
mock_adapter_factory.return_value = mock_adapter
|
|
|
|
mock_scorer = MagicMock()
|
|
mock_scorer.score_batch.return_value = []
|
|
mock_scorer_cls.return_value = mock_scorer
|
|
|
|
mock_store = MagicMock()
|
|
mock_store.get_listings_staged.return_value = {}
|
|
mock_store.refresh_seller_categories.return_value = 0
|
|
mock_store.save_listings.return_value = None
|
|
mock_store.save_trust_scores.return_value = None
|
|
mock_store.get_market_comp.return_value = None
|
|
mock_store.get_seller.return_value = None
|
|
mock_store.get_user_preference.return_value = None
|
|
mock_store_cls.return_value = mock_store
|
|
|
|
resp = client.get("/api/search/async?q=rtx+3080&refresh=true")
|
|
assert resp.status_code == 202
|
|
|
|
scraper_called.wait(timeout=5.0)
|
|
|
|
assert scraper_called.is_set(), "Scraper was not called even though refresh=True"
|