From 94497f92001eb543061067652fc201be536b630b Mon Sep 17 00:00:00 2001 From: counterweight Date: Mon, 22 Dec 2025 15:47:20 +0100 Subject: [PATCH] test: add unit tests for price history feature --- backend/tests/test_price_history.py | 240 ++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 backend/tests/test_price_history.py diff --git a/backend/tests/test_price_history.py b/backend/tests/test_price_history.py new file mode 100644 index 0000000..ffe3ef8 --- /dev/null +++ b/backend/tests/test_price_history.py @@ -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