arbret/backend/tests/test_price_history.py

294 lines
11 KiB
Python
Raw Normal View History

"""Tests for price history feature."""
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from models import PriceHistory
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
def create_mock_httpx_client(
json_response: list[Any] | dict[str, Any] | None = None,
raise_for_status_error: Exception | None = None,
get_error: Exception | None = None,
) -> AsyncMock:
"""
Create a mock httpx.AsyncClient for testing.
Args:
json_response: The JSON data to return from response.json()
raise_for_status_error: Exception to raise from response.raise_for_status()
get_error: Exception to raise from client.get()
Returns:
A mock AsyncClient configured as an async context manager.
"""
mock_client = AsyncMock()
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
if get_error:
mock_client.get.side_effect = get_error
else:
mock_response = MagicMock()
if raise_for_status_error:
mock_response.raise_for_status.side_effect = raise_for_status_error
else:
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = json_response
mock_client.get.return_value = mock_response
return mock_client
def create_bitfinex_ticker_response(price: float) -> list[float]:
"""Create a Bitfinex ticker response array with the given price at index 6."""
return [0, 0, 0, 0, 0, 0, price, 0, 0, 0]
class TestFetchBtcEurPrice:
"""Tests for the Bitfinex price fetcher."""
@pytest.mark.asyncio
async def test_parses_response_correctly(self):
"""Verify price is extracted from correct index in response array."""
# Full Bitfinex ticker format for documentation
json_response = [
100.0, # BID
10.0, # BID_SIZE
101.0, # ASK
10.0, # ASK_SIZE
1.0, # DAILY_CHANGE
0.01, # DAILY_CHANGE_RELATIVE
95000.50, # LAST_PRICE (index 6)
500.0, # VOLUME
96000.0, # HIGH
94000.0, # LOW
]
mock_client = create_mock_httpx_client(json_response=json_response)
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_client):
price, timestamp = await fetch_btc_eur_price()
assert price == 95000.50
assert isinstance(timestamp, datetime)
assert timestamp.tzinfo == UTC
@pytest.mark.asyncio
async def test_raises_on_http_error(self):
"""Verify HTTP errors are propagated."""
import httpx
error = httpx.HTTPStatusError(
"Server Error", request=MagicMock(), response=MagicMock()
)
mock_client = create_mock_httpx_client(raise_for_status_error=error)
with (
patch("price_fetcher.httpx.AsyncClient", return_value=mock_client),
pytest.raises(httpx.HTTPStatusError),
):
await fetch_btc_eur_price()
@pytest.mark.asyncio
async def test_raises_on_network_error(self):
"""Verify network errors (ConnectError, TimeoutException) are propagated."""
import httpx
error = httpx.ConnectError("Connection refused")
mock_client = create_mock_httpx_client(get_error=error)
with (
patch("price_fetcher.httpx.AsyncClient", return_value=mock_client),
pytest.raises(httpx.ConnectError),
):
await fetch_btc_eur_price()
@pytest.mark.asyncio
async def test_raises_on_invalid_response_format(self):
"""Verify ValueError is raised for unexpected response format."""
mock_client = create_mock_httpx_client(json_response={"error": "unexpected"})
with (
patch("price_fetcher.httpx.AsyncClient", return_value=mock_client),
pytest.raises(ValueError, match="Unexpected response format"),
):
await fetch_btc_eur_price()
@pytest.mark.asyncio
async def test_raises_on_short_array(self):
"""Verify ValueError is raised if array is too short."""
mock_client = create_mock_httpx_client(json_response=[1, 2, 3]) # Too short
with (
patch("price_fetcher.httpx.AsyncClient", return_value=mock_client),
pytest.raises(ValueError, match="Unexpected response format"),
):
await fetch_btc_eur_price()
class TestGetPriceHistory:
"""Tests for GET /api/audit/price-history endpoint."""
@pytest.mark.asyncio
async def test_requires_auth(self, client):
"""Verify unauthenticated requests are rejected."""
response = await client.get("/api/audit/price-history")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_requires_view_audit_permission(self, client_factory, regular_user):
"""Verify regular users cannot access price history."""
async with client_factory.create(cookies=regular_user["cookies"]) as authed:
response = await authed.get("/api/audit/price-history")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_admin_can_view_empty_list(self, client_factory, admin_user):
"""Verify admin can view price history (empty)."""
async with client_factory.create(cookies=admin_user["cookies"]) as authed:
response = await authed.get("/api/audit/price-history")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
async def test_returns_records_newest_first(self, client_factory, admin_user):
"""Verify records are returned in descending timestamp order."""
# Seed some price history records
async with client_factory.get_db_session() as db:
now = datetime.now(UTC)
for i in range(5):
record = PriceHistory(
source=SOURCE_BITFINEX,
pair=PAIR_BTC_EUR,
price=90000.0 + i * 100,
timestamp=now.replace(second=i),
)
db.add(record)
await db.commit()
async with client_factory.create(cookies=admin_user["cookies"]) as authed:
response = await authed.get("/api/audit/price-history")
assert response.status_code == 200
data = response.json()
assert len(data) == 5
# Newest first (highest second value = highest price in our test data)
assert data[0]["price"] == 90400.0
assert data[4]["price"] == 90000.0
@pytest.mark.asyncio
async def test_limits_to_20_records(self, client_factory, admin_user):
"""Verify only the 20 most recent records are returned."""
async with client_factory.get_db_session() as db:
now = datetime.now(UTC)
for i in range(25):
record = PriceHistory(
source=SOURCE_BITFINEX,
pair=PAIR_BTC_EUR,
price=90000.0 + i,
timestamp=now.replace(microsecond=i),
)
db.add(record)
await db.commit()
async with client_factory.create(cookies=admin_user["cookies"]) as authed:
response = await authed.get("/api/audit/price-history")
assert response.status_code == 200
data = response.json()
assert len(data) == 20
class TestManualFetch:
"""Tests for POST /api/audit/price-history/fetch endpoint."""
@pytest.mark.asyncio
async def test_requires_auth(self, client):
"""Verify unauthenticated requests are rejected."""
response = await client.post("/api/audit/price-history/fetch")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_requires_fetch_price_permission(self, client_factory, regular_user):
"""Verify regular users cannot trigger manual fetch."""
async with client_factory.create(cookies=regular_user["cookies"]) as authed:
response = await authed.post("/api/audit/price-history/fetch")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_creates_record_on_success(self, client_factory, admin_user):
"""Verify manual fetch creates a price history record."""
mock_client = create_mock_httpx_client(
json_response=create_bitfinex_ticker_response(95123.45)
)
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_client):
async with client_factory.create(cookies=admin_user["cookies"]) as authed:
response = await authed.post("/api/audit/price-history/fetch")
assert response.status_code == 200
data = response.json()
assert data["source"] == SOURCE_BITFINEX
assert data["pair"] == PAIR_BTC_EUR
assert data["price"] == 95123.45
assert "id" in data
assert "timestamp" in data
assert "created_at" in data
@pytest.mark.asyncio
async def test_record_persisted_to_database(self, client_factory, admin_user):
"""Verify the created record can be retrieved."""
mock_client = create_mock_httpx_client(
json_response=create_bitfinex_ticker_response(87654.32)
)
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_client):
async with client_factory.create(cookies=admin_user["cookies"]) as authed:
# Create record
await authed.post("/api/audit/price-history/fetch")
# Verify it appears in GET
response = await authed.get("/api/audit/price-history")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["price"] == 87654.32
@pytest.mark.asyncio
async def test_returns_existing_record_on_duplicate_timestamp(
self, client_factory, admin_user
):
"""Verify duplicate timestamp returns existing record instead of error."""
fixed_timestamp = datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC)
# Seed an existing record with the same timestamp we'll get from the mock
async with client_factory.get_db_session() as db:
existing = PriceHistory(
source=SOURCE_BITFINEX,
pair=PAIR_BTC_EUR,
price=90000.0,
timestamp=fixed_timestamp,
)
db.add(existing)
await db.commit()
await db.refresh(existing)
existing_id = existing.id
# Mock fetch_btc_eur_price to return the same timestamp
with patch("services.price.fetch_btc_eur_price") as mock_fetch:
mock_fetch.return_value = (95000.0, fixed_timestamp)
async with client_factory.create(cookies=admin_user["cookies"]) as authed:
response = await authed.post("/api/audit/price-history/fetch")
# Should succeed and return the existing record
assert response.status_code == 200
data = response.json()
assert data["id"] == existing_id
assert data["price"] == 90000.0 # Original price, not the new one