diff --git a/backend/tests/test_price_history.py b/backend/tests/test_price_history.py index ffe3ef8..ec20e84 100644 --- a/backend/tests/test_price_history.py +++ b/backend/tests/test_price_history.py @@ -1,5 +1,6 @@ """Tests for price history feature.""" +from contextlib import asynccontextmanager from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch @@ -7,6 +8,7 @@ import pytest from models import PriceHistory from price_fetcher import fetch_btc_eur_price +from worker import process_bitcoin_price_job class TestFetchBtcEurPrice: @@ -238,3 +240,91 @@ class TestManualFetch: 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] == "bitfinex" # source + assert call_args[0][2] == "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) diff --git a/backend/worker.py b/backend/worker.py index 4ebc123..d1a060f 100644 --- a/backend/worker.py +++ b/backend/worker.py @@ -98,6 +98,36 @@ def register_job_handlers(qm: QueueManager, db_pool: asyncpg.Pool) -> None: await process_random_number_job(job, db_pool) +async def process_bitcoin_price_job(db_pool: asyncpg.Pool) -> None: + """ + Fetch and store Bitcoin price from Bitfinex. + + This function is designed to fail silently - exceptions are caught and logged + so the scheduler can continue with the next scheduled run. + """ + try: + price, timestamp = await fetch_btc_eur_price() + + async with db_pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO price_history + (source, pair, price, timestamp, created_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (source, pair, timestamp) DO NOTHING + """, + "bitfinex", + "BTC/EUR", + price, + timestamp, + ) + + logger.info(f"Fetched BTC/EUR price: €{price:.2f}") + except Exception as e: + # Fail silently - next scheduled job will continue + logger.error(f"Failed to fetch Bitcoin price: {e}") + + def register_scheduled_jobs(sm: SchedulerManager, db_pool: asyncpg.Pool) -> None: """Register all scheduled jobs with the scheduler manager.""" @@ -105,27 +135,7 @@ def register_scheduled_jobs(sm: SchedulerManager, db_pool: asyncpg.Pool) -> None @sm.schedule(JOB_FETCH_BITCOIN_PRICE, "* * * * *") async def fetch_bitcoin_price(schedule: Schedule) -> None: """Fetch Bitcoin price from Bitfinex every minute.""" - try: - price, timestamp = await fetch_btc_eur_price() - - async with db_pool.acquire() as conn: - await conn.execute( - """ - INSERT INTO price_history - (source, pair, price, timestamp, created_at) - VALUES ($1, $2, $3, $4, NOW()) - ON CONFLICT (source, pair, timestamp) DO NOTHING - """, - "bitfinex", - "BTC/EUR", - price, - timestamp, - ) - - logger.info(f"Fetched BTC/EUR price: €{price:.2f}") - except Exception as e: - # Fail silently - next scheduled job will continue - logger.error(f"Failed to fetch Bitcoin price: {e}") + await process_bitcoin_price_job(db_pool) async def main() -> None: