arbret/backend/tests/test_price_history.py

241 lines
9.2 KiB
Python
Raw Normal View History

"""Tests for price history feature."""
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from models import PriceHistory
from price_fetcher import fetch_btc_eur_price
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."""
mock_response = MagicMock()
mock_response.json.return_value = [
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_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
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
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Server Error", request=MagicMock(), response=MagicMock()
)
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
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_invalid_response_format(self):
"""Verify ValueError is raised for unexpected response format."""
mock_response = MagicMock()
mock_response.json.return_value = {"error": "unexpected"}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
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_response = MagicMock()
mock_response.json.return_value = [1, 2, 3] # Too short
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
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="bitfinex",
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="bitfinex",
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_view_audit_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_response = MagicMock()
mock_response.json.return_value = [0, 0, 0, 0, 0, 0, 95123.45, 0, 0, 0]
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
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"] == "bitfinex"
assert data["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_response = MagicMock()
mock_response.json.return_value = [0, 0, 0, 0, 0, 0, 87654.32, 0, 0, 0]
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
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