"""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)