The POST /api/audit/price-history/fetch endpoint now requires FETCH_PRICE permission instead of VIEW_AUDIT, which is more semantically correct since it's a write operation.
368 lines
14 KiB
Python
368 lines
14 KiB
Python
"""Tests for price history feature."""
|
|
|
|
from contextlib import asynccontextmanager
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
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
|
|
|
|
|
|
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."""
|
|
# Full Bitfinex ticker format for documentation
|
|
json_response = [
|
|
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_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()
|
|
|
|
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
|
|
|
|
error = httpx.HTTPStatusError(
|
|
"Server Error", request=MagicMock(), response=MagicMock()
|
|
)
|
|
mock_client = create_mock_httpx_client(raise_for_status_error=error)
|
|
|
|
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_network_error(self):
|
|
"""Verify network errors (ConnectError, TimeoutException) are propagated."""
|
|
import httpx
|
|
|
|
error = httpx.ConnectError("Connection refused")
|
|
mock_client = create_mock_httpx_client(get_error=error)
|
|
|
|
with (
|
|
patch("price_fetcher.httpx.AsyncClient", return_value=mock_client),
|
|
pytest.raises(httpx.ConnectError),
|
|
):
|
|
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_client = create_mock_httpx_client(json_response={"error": "unexpected"})
|
|
|
|
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_client = create_mock_httpx_client(json_response=[1, 2, 3]) # Too short
|
|
|
|
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_fetch_price_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_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:
|
|
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_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:
|
|
# 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
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_existing_record_on_duplicate_timestamp(
|
|
self, client_factory, admin_user
|
|
):
|
|
"""Verify duplicate timestamp returns existing record instead of error."""
|
|
fixed_timestamp = datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC)
|
|
|
|
# Seed an existing record with the same timestamp we'll get from the mock
|
|
async with client_factory.get_db_session() as db:
|
|
existing = PriceHistory(
|
|
source=SOURCE_BITFINEX,
|
|
pair=PAIR_BTC_EUR,
|
|
price=90000.0,
|
|
timestamp=fixed_timestamp,
|
|
)
|
|
db.add(existing)
|
|
await db.commit()
|
|
await db.refresh(existing)
|
|
existing_id = existing.id
|
|
|
|
# Mock fetch_btc_eur_price to return the same timestamp
|
|
with patch("routes.audit.fetch_btc_eur_price") as mock_fetch:
|
|
mock_fetch.return_value = (95000.0, fixed_timestamp)
|
|
|
|
async with client_factory.create(cookies=admin_user["cookies"]) as authed:
|
|
response = await authed.post("/api/audit/price-history/fetch")
|
|
|
|
# Should succeed and return the existing record
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["id"] == existing_id
|
|
assert data["price"] == 90000.0 # Original price, not the new one
|
|
|
|
|
|
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_http_client = create_mock_httpx_client(
|
|
json_response=create_bitfinex_ticker_response(95000.0)
|
|
)
|
|
|
|
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
|
|
|
|
error = httpx.HTTPStatusError(
|
|
"Server Error", request=MagicMock(), response=MagicMock()
|
|
)
|
|
mock_http_client = create_mock_httpx_client(raise_for_status_error=error)
|
|
|
|
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_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")
|
|
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)
|