Files
vault-dash/tests/test_data_service_normalization.py

336 lines
13 KiB
Python

"""Tests for DataService quote payload normalization and cache serialization boundaries."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from app.services.cache import CacheService
from app.services.data_service import DataService
class _CacheStub(CacheService):
"""In-memory cache stub for unit testing."""
def __init__(self, initial: dict[str, object] | None = None) -> None:
self._store = dict(initial or {})
self.write_count = 0
async def get_json(self, key: str): # type: ignore[override]
return self._store.get(key)
async def set_json(self, key: str, value): # type: ignore[override]
self._store[key] = value
self.write_count += 1
return True
class TestQuotePayloadNormalization:
"""Test quote payload normalization edge cases and fail-closed behavior."""
@pytest.mark.asyncio
async def test_normalize_quote_adds_missing_symbol(self) -> None:
"""Normalization should add symbol when missing from payload."""
payload = {"price": 100.0, "change": 1.0}
result = DataService._normalize_quote_payload(payload, "AAPL")
assert result["symbol"] == "AAPL"
assert result["price"] == 100.0
@pytest.mark.asyncio
async def test_normalize_quote_uppercases_symbol(self) -> None:
"""Normalization should uppercase symbol for consistency."""
payload = {"symbol": "gld", "price": 100.0}
result = DataService._normalize_quote_payload(payload, "gld")
assert result["symbol"] == "GLD"
@pytest.mark.asyncio
async def test_normalize_quote_rejects_explicit_symbol_mismatch(self) -> None:
"""Explicit payload/key mismatches should be rejected, not silently rewritten."""
payload = {"symbol": "SLV", "price": 100.0}
with pytest.raises(ValueError, match="Quote payload symbol mismatch"):
DataService._normalize_quote_payload(payload, "GLD")
@pytest.mark.asyncio
async def test_normalize_quote_preserves_existing_quote_unit(self) -> None:
"""Normalization should not overwrite existing quote_unit."""
payload = {"symbol": "AAPL", "price": 100.0, "quote_unit": "share"}
result = DataService._normalize_quote_payload(payload, "AAPL")
assert result["quote_unit"] == "share"
@pytest.mark.asyncio
async def test_normalize_quote_repairs_empty_gld_quote_unit(self) -> None:
"""Legacy or malformed empty GLD quote units should be repaired."""
payload = {"symbol": "GLD", "price": 200.0, "quote_unit": ""}
result = DataService._normalize_quote_payload(payload, "GLD")
assert result["quote_unit"] == "share"
@pytest.mark.asyncio
async def test_normalize_quote_adds_gld_share_unit(self) -> None:
"""GLD quotes should get quote_unit=share when missing."""
payload = {"symbol": "GLD", "price": 200.0}
result = DataService._normalize_quote_payload(payload, "GLD")
assert result["quote_unit"] == "share"
@pytest.mark.asyncio
async def test_normalize_quote_does_not_add_unit_for_unknown_symbols(self) -> None:
"""Non-GLD symbols should not get auto-assigned quote_unit."""
payload = {"symbol": "BTC-USD", "price": 50000.0}
result = DataService._normalize_quote_payload(payload, "BTC-USD")
assert "quote_unit" not in result
@pytest.mark.asyncio
async def test_normalize_quote_handles_missing_price_gracefully(self) -> None:
"""Missing price should be preserved as-is for fail-closed handling."""
payload = {"symbol": "GLD"} # type: ignore[dict-item]
result = DataService._normalize_quote_payload(payload, "GLD")
assert "price" not in result
assert result["symbol"] == "GLD"
assert result["quote_unit"] == "share"
@pytest.mark.asyncio
async def test_normalize_quote_handles_none_values(self) -> None:
"""None values in payload should be preserved for upstream handling."""
payload = {"symbol": "GLD", "price": None, "change": None}
result = DataService._normalize_quote_payload(payload, "GLD")
assert result["symbol"] == "GLD"
assert result["price"] is None
assert result["change"] is None
@pytest.mark.asyncio
async def test_normalize_quote_preserves_extra_fields(self) -> None:
"""Extra fields should pass through unchanged."""
payload = {
"symbol": "GLD",
"price": 200.0,
"custom_field": "custom_value",
"nested": {"key": "value"},
}
result = DataService._normalize_quote_payload(payload, "GLD")
assert result["custom_field"] == "custom_value"
assert result["nested"] == {"key": "value"}
@pytest.mark.asyncio
async def test_normalize_quote_handles_string_price(self) -> None:
"""String prices should pass through (normalization is not type conversion)."""
payload = {"symbol": "GLD", "price": "200.0"}
result = DataService._normalize_quote_payload(payload, "GLD")
assert result["price"] == "200.0"
assert result["quote_unit"] == "share"
class TestLegacyCachedQuoteHandling:
"""Test handling of legacy cached quotes without quote_unit metadata."""
@pytest.mark.asyncio
async def test_get_quote_upgrades_legacy_gld_quote(self) -> None:
"""Legacy GLD quotes without quote_unit should be upgraded in cache."""
cache = _CacheStub(
{
"quote:GLD": {
"symbol": "GLD",
"price": 404.19,
"change": 0.0,
"change_percent": 0.0,
"updated_at": "2026-03-25T00:00:00+00:00",
"source": "cache",
}
}
)
service = DataService(cache=cache)
quote = await service.get_quote("GLD")
assert quote["quote_unit"] == "share"
assert cache._store["quote:GLD"]["quote_unit"] == "share"
# Should have written back the normalized version
assert cache.write_count >= 1
@pytest.mark.asyncio
async def test_get_quote_preserves_already_normalized_gld_quote(self) -> None:
"""Already-normalized GLD quotes should not trigger cache rewrite."""
cache = _CacheStub(
{
"quote:GLD": {
"symbol": "GLD",
"price": 404.19,
"quote_unit": "share",
"change": 0.0,
"change_percent": 0.0,
"updated_at": "2026-03-25T00:00:00+00:00",
"source": "cache",
}
}
)
service = DataService(cache=cache)
quote = await service.get_quote("GLD")
assert quote["quote_unit"] == "share"
# Should not have rewritten the cache
assert cache.write_count == 0
@pytest.mark.asyncio
async def test_get_quote_preserves_missing_unit_for_non_gld(self) -> None:
"""Non-GLD symbols should not get auto-assigned quote_unit."""
cache = _CacheStub(
{
"quote:BTC-USD": {
"symbol": "BTC-USD",
"price": 70000.0,
"updated_at": "2026-03-25T00:00:00+00:00",
"source": "cache",
}
}
)
service = DataService(cache=cache, default_underlying="BTC-USD")
quote = await service.get_quote("BTC-USD")
assert "quote_unit" not in quote
assert cache.write_count == 0
@pytest.mark.asyncio
async def test_get_quote_handles_malformed_cached_quote(self) -> None:
"""Malformed cached quotes (missing critical fields) should pass through."""
cache = _CacheStub(
{
"quote:GLD": {
"symbol": "GLD",
# missing price, change, etc.
}
}
)
service = DataService(cache=cache)
quote = await service.get_quote("GLD")
# Should still normalize what's present
assert quote["symbol"] == "GLD"
assert quote["quote_unit"] == "share"
# Missing fields remain missing for fail-closed handling upstream
@pytest.mark.asyncio
async def test_get_quote_ignores_mismatched_cached_symbol_and_fetches_fresh(self) -> None:
"""Symbol-mismatched cached quotes should be discarded, not rewritten."""
cache = _CacheStub({"quote:GLD": {"symbol": "SLV", "price": 28.5, "source": "cache"}})
service = DataService(cache=cache)
async def _fake_fetch(symbol: str) -> dict[str, object]:
return {"symbol": symbol, "price": 404.19, "source": "provider"}
service._fetch_quote = _fake_fetch # type: ignore[method-assign]
quote = await service.get_quote("GLD")
assert quote["symbol"] == "GLD"
assert quote["source"] == "provider"
assert quote["quote_unit"] == "share"
class TestCacheSerializationBoundaries:
"""Test cache serialization behavior and JSON expectations."""
@pytest.mark.asyncio
async def test_cache_roundtrip_preserves_quote_structure(self) -> None:
"""Cache should preserve quote structure through JSON roundtrip."""
cache = _CacheStub()
# Simulate a fresh quote fetch
quote = {
"symbol": "GLD",
"price": 200.0,
"quote_unit": "share",
"change": 1.5,
"change_percent": 0.75,
"updated_at": datetime.now(timezone.utc).isoformat(),
"source": "yfinance",
}
# Store and retrieve
await cache.set_json("quote:GLD", quote)
retrieved = await cache.get_json("quote:GLD")
assert retrieved is not None
assert retrieved["symbol"] == "GLD"
assert retrieved["price"] == 200.0
assert retrieved["quote_unit"] == "share"
@pytest.mark.asyncio
async def test_cache_handles_datetime_in_payload(self) -> None:
"""Cache serialization should handle datetime objects in payloads.
Note: _CacheStub stores raw Python objects. The real Redis cache
serializes via json.dumps with a datetime default handler.
This test documents the expected behavior for the real cache.
"""
cache = _CacheStub()
quote_with_dt = {
"symbol": "GLD",
"price": 200.0,
"quote_unit": "share",
"updated_at": datetime.now(timezone.utc),
}
# In real usage, DataService always passes ISO strings to cache
# (see _fetch_quote and other quote producers)
# The stub just passes through the object
await cache.set_json("quote:GLD", quote_with_dt)
retrieved = await cache.get_json("quote:GLD")
assert retrieved is not None
assert retrieved["symbol"] == "GLD"
# Stub preserves the datetime object; real cache would serialize to ISO string
# What matters is that retrieval works without error
assert "updated_at" in retrieved
@pytest.mark.asyncio
async def test_cache_returns_none_for_missing_key(self) -> None:
"""Cache should return None for missing keys."""
cache = _CacheStub()
result = await cache.get_json("quote:NONEXISTENT")
assert result is None
@pytest.mark.asyncio
async def test_cache_handles_empty_dict(self) -> None:
"""Cache should handle empty dict payloads."""
cache = _CacheStub()
await cache.set_json("quote:EMPTY", {})
result = await cache.get_json("quote:EMPTY")
assert result == {}
class TestQuotePayloadValidation:
"""Test validation behavior for quote payloads at cache boundary."""
@pytest.mark.asyncio
async def test_get_quote_validates_cached_dict_type(self) -> None:
"""Non-dict cached values should be ignored."""
cache = _CacheStub({"quote:GLD": "not-a-dict"}) # type: ignore[dict-item]
service = DataService(cache=cache)
# Should not use the invalid cached value
# (will fall through to fetch, which will use fallback)
quote = await service.get_quote("GLD")
assert quote["symbol"] == "GLD"
assert quote["quote_unit"] == "share"
@pytest.mark.asyncio
async def test_get_quote_handles_list_cached_value(self) -> None:
"""List cached values should be ignored for quote lookups."""
cache = _CacheStub({"quote:GLD": [1, 2, 3]}) # type: ignore[dict-item]
service = DataService(cache=cache)
quote = await service.get_quote("GLD")
assert quote["symbol"] == "GLD"
@pytest.mark.asyncio
async def test_normalize_handles_empty_payload(self) -> None:
"""Empty payload should be handled gracefully."""
payload = {}
result = DataService._normalize_quote_payload(payload, "GLD")
assert result["symbol"] == "GLD"
assert result["quote_unit"] == "share"