mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-24 14:06:31 +01:00
308 lines
11 KiB
Python
308 lines
11 KiB
Python
|
|
"""Tests for PriceService - price lookup, caching, and batch operations."""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import time
|
||
|
|
import threading
|
||
|
|
from typing import Any, Dict
|
||
|
|
from unittest.mock import patch, MagicMock
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from code.web.services.price_service import PriceService
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Fixtures
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def _make_bulk_line(name: str, usd: str = None, eur: str = None, usd_foil: str = None) -> str:
|
||
|
|
"""Return a JSON line matching Scryfall bulk data format."""
|
||
|
|
card: Dict[str, Any] = {
|
||
|
|
"object": "card",
|
||
|
|
"name": name,
|
||
|
|
"prices": {
|
||
|
|
"usd": usd,
|
||
|
|
"usd_foil": usd_foil,
|
||
|
|
"eur": eur,
|
||
|
|
"eur_foil": None,
|
||
|
|
"tix": None,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
return json.dumps(card)
|
||
|
|
|
||
|
|
|
||
|
|
def _write_bulk_data(path: str, cards: list) -> None:
|
||
|
|
"""Write a minimal Scryfall bulk data JSON array (one card per line)."""
|
||
|
|
with open(path, "w", encoding="utf-8") as fh:
|
||
|
|
fh.write("[\n")
|
||
|
|
for i, card in enumerate(cards):
|
||
|
|
suffix = "," if i < len(cards) - 1 else ""
|
||
|
|
fh.write(json.dumps(card) + suffix + "\n")
|
||
|
|
fh.write("]\n")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def bulk_data_file(tmp_path):
|
||
|
|
"""Minimal Scryfall bulk data with known prices."""
|
||
|
|
cards = [
|
||
|
|
{"object": "card", "name": "Lightning Bolt", "prices": {"usd": "0.50", "usd_foil": "2.00", "eur": "0.40", "eur_foil": None, "tix": None}},
|
||
|
|
{"object": "card", "name": "Sol Ring", "prices": {"usd": "2.00", "usd_foil": "5.00", "eur": "1.80", "eur_foil": None, "tix": None}},
|
||
|
|
{"object": "card", "name": "Mana Crypt", "prices": {"usd": "150.00", "usd_foil": "300.00", "eur": "120.00", "eur_foil": None, "tix": None}},
|
||
|
|
# Card with no price
|
||
|
|
{"object": "card", "name": "Unpriced Card", "prices": {"usd": None, "usd_foil": None, "eur": None, "eur_foil": None, "tix": None}},
|
||
|
|
# Second printing of Lightning Bolt (cheaper)
|
||
|
|
{"object": "card", "name": "Lightning Bolt", "prices": {"usd": "0.25", "usd_foil": "1.00", "eur": "0.20", "eur_foil": None, "tix": None}},
|
||
|
|
# DFC card
|
||
|
|
{"object": "card", "name": "Delver of Secrets // Insectile Aberration", "prices": {"usd": "1.50", "usd_foil": "8.00", "eur": "1.20", "eur_foil": None, "tix": None}},
|
||
|
|
]
|
||
|
|
path = str(tmp_path / "scryfall_bulk_data.json")
|
||
|
|
_write_bulk_data(path, cards)
|
||
|
|
return path
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def price_svc(bulk_data_file, tmp_path):
|
||
|
|
"""PriceService pointed at the test bulk data, with a temporary cache path."""
|
||
|
|
cache_path = str(tmp_path / "prices_cache.json")
|
||
|
|
return PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path, cache_ttl=3600)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Tests: single price lookup
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def test_get_price_known_card(price_svc):
|
||
|
|
price = price_svc.get_price("Lightning Bolt")
|
||
|
|
# Should return the cheapest printing (0.25, not 0.50)
|
||
|
|
assert price == pytest.approx(0.25)
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_price_case_insensitive(price_svc):
|
||
|
|
assert price_svc.get_price("lightning bolt") == price_svc.get_price("LIGHTNING BOLT")
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_price_foil(price_svc):
|
||
|
|
foil = price_svc.get_price("Lightning Bolt", foil=True)
|
||
|
|
# Cheapest foil printing
|
||
|
|
assert foil == pytest.approx(1.00)
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_price_eur_region(price_svc):
|
||
|
|
price = price_svc.get_price("Sol Ring", region="eur")
|
||
|
|
assert price == pytest.approx(1.80)
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_price_unknown_card_returns_none(price_svc):
|
||
|
|
assert price_svc.get_price("Nonexistent Card Name XYZ") is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_price_unpriced_card_returns_none(price_svc):
|
||
|
|
assert price_svc.get_price("Unpriced Card") is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_price_expensive_card(price_svc):
|
||
|
|
assert price_svc.get_price("Mana Crypt") == pytest.approx(150.00)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Tests: DFC card name indexing
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def test_get_price_dfc_combined_name(price_svc):
|
||
|
|
price = price_svc.get_price("Delver of Secrets // Insectile Aberration")
|
||
|
|
assert price == pytest.approx(1.50)
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_price_dfc_front_face_name(price_svc):
|
||
|
|
"""Front face name alone should resolve to the DFC price."""
|
||
|
|
price = price_svc.get_price("Delver of Secrets")
|
||
|
|
assert price == pytest.approx(1.50)
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_price_dfc_back_face_name(price_svc):
|
||
|
|
"""Back face name alone should also resolve."""
|
||
|
|
price = price_svc.get_price("Insectile Aberration")
|
||
|
|
assert price == pytest.approx(1.50)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Tests: batch lookup
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def test_get_prices_batch_all_found(price_svc):
|
||
|
|
result = price_svc.get_prices_batch(["Lightning Bolt", "Sol Ring"])
|
||
|
|
assert result["Lightning Bolt"] == pytest.approx(0.25)
|
||
|
|
assert result["Sol Ring"] == pytest.approx(2.00)
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_prices_batch_mixed_found_missing(price_svc):
|
||
|
|
result = price_svc.get_prices_batch(["Lightning Bolt", "Unknown Card"])
|
||
|
|
assert result["Lightning Bolt"] is not None
|
||
|
|
assert result["Unknown Card"] is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_prices_batch_empty_list(price_svc):
|
||
|
|
assert price_svc.get_prices_batch([]) == {}
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_prices_batch_preserves_original_case(price_svc):
|
||
|
|
result = price_svc.get_prices_batch(["LIGHTNING BOLT"])
|
||
|
|
# Key should match input case exactly
|
||
|
|
assert "LIGHTNING BOLT" in result
|
||
|
|
assert result["LIGHTNING BOLT"] == pytest.approx(0.25)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Tests: cache persistence
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def test_rebuild_writes_cache_file(price_svc, tmp_path):
|
||
|
|
# Trigger load → rebuild
|
||
|
|
price_svc.get_price("Sol Ring")
|
||
|
|
assert os.path.exists(price_svc._cache_path)
|
||
|
|
|
||
|
|
|
||
|
|
def test_cache_file_has_expected_structure(price_svc, tmp_path):
|
||
|
|
price_svc.get_price("Sol Ring")
|
||
|
|
with open(price_svc._cache_path, "r", encoding="utf-8") as fh:
|
||
|
|
data = json.load(fh)
|
||
|
|
assert "prices" in data
|
||
|
|
assert "built_at" in data
|
||
|
|
assert "sol ring" in data["prices"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_fresh_cache_loaded_without_rebuild(bulk_data_file, tmp_path):
|
||
|
|
"""Second PriceService instance should load from cache, not rebuild."""
|
||
|
|
cache_path = str(tmp_path / "prices_cache.json")
|
||
|
|
|
||
|
|
svc1 = PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path)
|
||
|
|
svc1.get_price("Sol Ring") # triggers rebuild → writes cache
|
||
|
|
|
||
|
|
rebuild_calls = []
|
||
|
|
svc2 = PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path)
|
||
|
|
orig_rebuild = svc2._rebuild_cache
|
||
|
|
|
||
|
|
def patched_rebuild():
|
||
|
|
rebuild_calls.append(1)
|
||
|
|
orig_rebuild()
|
||
|
|
|
||
|
|
svc2._rebuild_cache = patched_rebuild
|
||
|
|
svc2.get_price("Sol Ring") # should load from cache, not rebuild
|
||
|
|
|
||
|
|
assert rebuild_calls == [], "Second instance should not rebuild when cache is fresh"
|
||
|
|
|
||
|
|
|
||
|
|
def test_stale_cache_triggers_rebuild(bulk_data_file, tmp_path):
|
||
|
|
"""Cache older than TTL should trigger a rebuild."""
|
||
|
|
cache_path = str(tmp_path / "prices_cache.json")
|
||
|
|
|
||
|
|
# Write a valid but stale cache file
|
||
|
|
stale_data = {
|
||
|
|
"prices": {"sol ring": {"usd": 2.0}},
|
||
|
|
"built_at": time.time() - 99999, # very old
|
||
|
|
}
|
||
|
|
with open(cache_path, "w") as fh:
|
||
|
|
json.dump(stale_data, fh)
|
||
|
|
# Set mtime to old as well
|
||
|
|
old_time = time.time() - 99999
|
||
|
|
os.utime(cache_path, (old_time, old_time))
|
||
|
|
|
||
|
|
rebuild_calls = []
|
||
|
|
svc = PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path, cache_ttl=3600)
|
||
|
|
orig_rebuild = svc._rebuild_cache
|
||
|
|
|
||
|
|
def patched_rebuild():
|
||
|
|
rebuild_calls.append(1)
|
||
|
|
orig_rebuild()
|
||
|
|
|
||
|
|
svc._rebuild_cache = patched_rebuild
|
||
|
|
svc.get_price("Sol Ring")
|
||
|
|
|
||
|
|
assert rebuild_calls == [1], "Stale cache should trigger a rebuild"
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Tests: cache stats / telemetry
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def test_cache_stats_structure(price_svc):
|
||
|
|
price_svc.get_price("Sol Ring")
|
||
|
|
stats = price_svc.cache_stats()
|
||
|
|
assert "total_entries" in stats
|
||
|
|
assert "hit_count" in stats
|
||
|
|
assert "miss_count" in stats
|
||
|
|
assert "hit_rate" in stats
|
||
|
|
assert "loaded" in stats
|
||
|
|
assert stats["loaded"] is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_cache_stats_hit_miss_counts(price_svc):
|
||
|
|
price_svc.get_price("Sol Ring") # hit
|
||
|
|
price_svc.get_price("Unknown Card") # miss
|
||
|
|
stats = price_svc.cache_stats()
|
||
|
|
assert stats["hit_count"] >= 1
|
||
|
|
assert stats["miss_count"] >= 1
|
||
|
|
|
||
|
|
|
||
|
|
def test_cache_stats_hit_rate_zero_before_load():
|
||
|
|
"""Before any lookups, hit_rate should be 0."""
|
||
|
|
svc = PriceService(bulk_data_path="/nonexistent", cache_path="/nonexistent/cache.json")
|
||
|
|
# Don't trigger _ensure_loaded - call cache_stats indirectly via a direct check
|
||
|
|
# We expect loaded=False and hit_rate=0
|
||
|
|
# Note: cache_stats calls _ensure_loaded, so bulk_data missing → cache remains empty
|
||
|
|
stats = svc.cache_stats()
|
||
|
|
assert stats["hit_rate"] == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Tests: background refresh
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def test_refresh_cache_background_starts_thread(price_svc):
|
||
|
|
price_svc.get_price("Sol Ring") # ensure loaded
|
||
|
|
price_svc.refresh_cache_background()
|
||
|
|
# Allow thread to start
|
||
|
|
time.sleep(0.05)
|
||
|
|
# Thread should have run (or be running)
|
||
|
|
assert price_svc._refresh_thread is not None
|
||
|
|
|
||
|
|
|
||
|
|
def test_refresh_cache_background_no_duplicate_threads(price_svc):
|
||
|
|
price_svc.get_price("Sol Ring")
|
||
|
|
price_svc.refresh_cache_background()
|
||
|
|
t1 = price_svc._refresh_thread
|
||
|
|
price_svc.refresh_cache_background() # second call while thread running
|
||
|
|
t2 = price_svc._refresh_thread
|
||
|
|
assert t1 is t2, "Should not spawn a second refresh thread"
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Tests: missing / corrupted bulk data
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def test_missing_bulk_data_returns_none(tmp_path):
|
||
|
|
svc = PriceService(
|
||
|
|
bulk_data_path=str(tmp_path / "nonexistent.json"),
|
||
|
|
cache_path=str(tmp_path / "cache.json"),
|
||
|
|
)
|
||
|
|
assert svc.get_price("Sol Ring") is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_corrupted_bulk_data_line_skipped(tmp_path):
|
||
|
|
"""Malformed JSON lines should be skipped without crashing."""
|
||
|
|
bulk_path = str(tmp_path / "bulk.json")
|
||
|
|
with open(bulk_path, "w") as fh:
|
||
|
|
fh.write("[\n")
|
||
|
|
fh.write('{"object":"card","name":"Sol Ring","prices":{"usd":"2.00","usd_foil":null,"eur":null,"eur_foil":null,"tix":null}}\n')
|
||
|
|
fh.write("NOT VALID JSON,,,,\n")
|
||
|
|
fh.write("]")
|
||
|
|
|
||
|
|
svc = PriceService(
|
||
|
|
bulk_data_path=bulk_path,
|
||
|
|
cache_path=str(tmp_path / "cache.json"),
|
||
|
|
)
|
||
|
|
# Should still find Sol Ring despite corrupted line
|
||
|
|
assert svc.get_price("Sol Ring") == pytest.approx(2.00)
|