test: add unit tests for price history feature
This commit is contained in:
parent
e3b047f782
commit
94497f9200
1 changed files with 240 additions and 0 deletions
240
backend/tests/test_price_history.py
Normal file
240
backend/tests/test_price_history.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue