From a5488fd20b1022e1a74a7b84eaf38cbbaf2babc0 Mon Sep 17 00:00:00 2001 From: counterweight Date: Mon, 22 Dec 2025 16:09:05 +0100 Subject: [PATCH] fix: handle unique constraint violation in manual fetch endpoint When a duplicate timestamp occurs (rare but possible), return the existing record instead of failing with a 500 error. This matches the worker's ON CONFLICT DO NOTHING behavior. Added test for duplicate timestamp handling. --- backend/routes/audit.py | 17 +++++++++++++-- backend/tests/test_price_history.py | 33 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/backend/routes/audit.py b/backend/routes/audit.py index d3709fc..3b35075 100644 --- a/backend/routes/audit.py +++ b/backend/routes/audit.py @@ -6,6 +6,7 @@ from typing import TypeVar from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from sqlalchemy import desc, func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from auth import require_permission @@ -200,8 +201,20 @@ async def fetch_price_now( timestamp=timestamp, ) db.add(record) - await db.commit() - await db.refresh(record) + + try: + await db.commit() + await db.refresh(record) + except IntegrityError: + # Duplicate timestamp - return the existing record + await db.rollback() + query = select(PriceHistory).where( + PriceHistory.source == SOURCE_BITFINEX, + PriceHistory.pair == PAIR_BTC_EUR, + PriceHistory.timestamp == timestamp, + ) + result = await db.execute(query) + record = result.scalar_one() return PriceHistoryResponse( id=record.id, diff --git a/backend/tests/test_price_history.py b/backend/tests/test_price_history.py index f0a6bc6..2dacaee 100644 --- a/backend/tests/test_price_history.py +++ b/backend/tests/test_price_history.py @@ -241,6 +241,39 @@ class TestManualFetch: 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."""