arbret/backend/tests/test_price_history.py
counterweight ec835a2935
refactor: extract 'bitfinex' and 'BTC/EUR' magic strings to constants
Add SOURCE_BITFINEX and PAIR_BTC_EUR constants in price_fetcher.py and
use them consistently in routes/audit.py, worker.py, and tests.
2025-12-22 16:06:56 +01:00

330 lines
13 KiB
Python

"""Tests for price history feature."""
from contextlib import asynccontextmanager
from datetime import UTC, datetime
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
from worker import process_bitcoin_price_job
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=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_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"] == 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_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
def create_mock_pool(mock_conn: AsyncMock) -> MagicMock:
"""Create a mock asyncpg pool with proper async context manager behavior."""
mock_pool = MagicMock()
@asynccontextmanager
async def mock_acquire():
yield mock_conn
mock_pool.acquire = mock_acquire
return mock_pool
class TestProcessBitcoinPriceJob:
"""Tests for the scheduled Bitcoin price job handler."""
@pytest.mark.asyncio
async def test_stores_price_on_success(self):
"""Verify price is stored in database on successful fetch."""
mock_response = MagicMock()
mock_response.json.return_value = [0, 0, 0, 0, 0, 0, 95000.0, 0, 0, 0]
mock_response.raise_for_status = MagicMock()
mock_http_client = AsyncMock()
mock_http_client.get.return_value = mock_response
mock_http_client.__aenter__.return_value = mock_http_client
mock_http_client.__aexit__.return_value = None
mock_conn = AsyncMock()
mock_pool = create_mock_pool(mock_conn)
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_http_client):
await process_bitcoin_price_job(mock_pool)
# Verify execute was called with correct values
mock_conn.execute.assert_called_once()
call_args = mock_conn.execute.call_args
# Check the SQL parameters
assert call_args[0][1] == SOURCE_BITFINEX # source
assert call_args[0][2] == PAIR_BTC_EUR # pair
assert call_args[0][3] == 95000.0 # price
@pytest.mark.asyncio
async def test_fails_silently_on_api_error(self):
"""Verify no exception is raised and no DB insert on API error."""
import httpx
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Server Error", request=MagicMock(), response=MagicMock()
)
mock_http_client = AsyncMock()
mock_http_client.get.return_value = mock_response
mock_http_client.__aenter__.return_value = mock_http_client
mock_http_client.__aexit__.return_value = None
mock_conn = AsyncMock()
mock_pool = create_mock_pool(mock_conn)
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_http_client):
# Should not raise an exception
await process_bitcoin_price_job(mock_pool)
# Should not have called execute
mock_conn.execute.assert_not_called()
@pytest.mark.asyncio
async def test_fails_silently_on_db_error(self):
"""Verify no exception is raised on database error."""
mock_response = MagicMock()
mock_response.json.return_value = [0, 0, 0, 0, 0, 0, 95000.0, 0, 0, 0]
mock_response.raise_for_status = MagicMock()
mock_http_client = AsyncMock()
mock_http_client.get.return_value = mock_response
mock_http_client.__aenter__.return_value = mock_http_client
mock_http_client.__aexit__.return_value = None
mock_conn = AsyncMock()
mock_conn.execute.side_effect = Exception("Database connection error")
mock_pool = create_mock_pool(mock_conn)
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_http_client):
# Should not raise an exception
await process_bitcoin_price_job(mock_pool)