From 54709888e19e7f3601d4295ec01963542e2bc1b0 Mon Sep 17 00:00:00 2001 From: counterweight Date: Mon, 22 Dec 2025 16:21:18 +0100 Subject: [PATCH] refactor: extract httpx mock helpers in price history tests - create_mock_httpx_client() for mocking AsyncClient with various configs - create_bitfinex_ticker_response() for creating ticker response arrays Reduces test boilerplate significantly. --- backend/tests/test_price_history.py | 137 +++++++++++++--------------- 1 file changed, 63 insertions(+), 74 deletions(-) diff --git a/backend/tests/test_price_history.py b/backend/tests/test_price_history.py index 200e4a0..a22eaac 100644 --- a/backend/tests/test_price_history.py +++ b/backend/tests/test_price_history.py @@ -2,6 +2,7 @@ from contextlib import asynccontextmanager from datetime import UTC, datetime +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -11,14 +12,53 @@ from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price from worker import process_bitcoin_price_job +def create_mock_httpx_client( + json_response: list[Any] | dict[str, Any] | None = None, + raise_for_status_error: Exception | None = None, + get_error: Exception | None = None, +) -> AsyncMock: + """ + Create a mock httpx.AsyncClient for testing. + + Args: + json_response: The JSON data to return from response.json() + raise_for_status_error: Exception to raise from response.raise_for_status() + get_error: Exception to raise from client.get() + + Returns: + A mock AsyncClient configured as an async context manager. + """ + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + if get_error: + mock_client.get.side_effect = get_error + else: + mock_response = MagicMock() + if raise_for_status_error: + mock_response.raise_for_status.side_effect = raise_for_status_error + else: + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = json_response + mock_client.get.return_value = mock_response + + return mock_client + + +def create_bitfinex_ticker_response(price: float) -> list[float]: + """Create a Bitfinex ticker response array with the given price at index 6.""" + return [0, 0, 0, 0, 0, 0, price, 0, 0, 0] + + 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 = [ + # Full Bitfinex ticker format for documentation + json_response = [ 100.0, # BID 10.0, # BID_SIZE 101.0, # ASK @@ -30,12 +70,7 @@ class TestFetchBtcEurPrice: 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 + mock_client = create_mock_httpx_client(json_response=json_response) with patch("price_fetcher.httpx.AsyncClient", return_value=mock_client): price, timestamp = await fetch_btc_eur_price() @@ -49,15 +84,10 @@ class TestFetchBtcEurPrice: """Verify HTTP errors are propagated.""" import httpx - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + error = 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 + mock_client = create_mock_httpx_client(raise_for_status_error=error) with ( patch("price_fetcher.httpx.AsyncClient", return_value=mock_client), @@ -70,10 +100,8 @@ class TestFetchBtcEurPrice: """Verify network errors (ConnectError, TimeoutException) are propagated.""" import httpx - mock_client = AsyncMock() - mock_client.get.side_effect = httpx.ConnectError("Connection refused") - mock_client.__aenter__.return_value = mock_client - mock_client.__aexit__.return_value = None + error = httpx.ConnectError("Connection refused") + mock_client = create_mock_httpx_client(get_error=error) with ( patch("price_fetcher.httpx.AsyncClient", return_value=mock_client), @@ -84,14 +112,7 @@ class TestFetchBtcEurPrice: @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 + mock_client = create_mock_httpx_client(json_response={"error": "unexpected"}) with ( patch("price_fetcher.httpx.AsyncClient", return_value=mock_client), @@ -102,14 +123,7 @@ class TestFetchBtcEurPrice: @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 + mock_client = create_mock_httpx_client(json_response=[1, 2, 3]) # Too short with ( patch("price_fetcher.httpx.AsyncClient", return_value=mock_client), @@ -210,14 +224,9 @@ class TestManualFetch: @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 + mock_client = create_mock_httpx_client( + json_response=create_bitfinex_ticker_response(95123.45) + ) with patch("price_fetcher.httpx.AsyncClient", return_value=mock_client): async with client_factory.create(cookies=admin_user["cookies"]) as authed: @@ -235,14 +244,9 @@ class TestManualFetch: @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 + mock_client = create_mock_httpx_client( + json_response=create_bitfinex_ticker_response(87654.32) + ) with patch("price_fetcher.httpx.AsyncClient", return_value=mock_client): async with client_factory.create(cookies=admin_user["cookies"]) as authed: @@ -309,14 +313,9 @@ class TestProcessBitcoinPriceJob: @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_http_client = create_mock_httpx_client( + json_response=create_bitfinex_ticker_response(95000.0) + ) mock_conn = AsyncMock() mock_pool = create_mock_pool(mock_conn) @@ -338,15 +337,10 @@ class TestProcessBitcoinPriceJob: """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( + error = 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_http_client = create_mock_httpx_client(raise_for_status_error=error) mock_conn = AsyncMock() mock_pool = create_mock_pool(mock_conn) @@ -361,14 +355,9 @@ class TestProcessBitcoinPriceJob: @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_http_client = create_mock_httpx_client( + json_response=create_bitfinex_ticker_response(95000.0) + ) mock_conn = AsyncMock() mock_conn.execute.side_effect = Exception("Database connection error")