mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-04-25 08:26:21 +02:00
120 lines
4 KiB
Python
120 lines
4 KiB
Python
|
|
import asyncio
|
||
|
|
import io
|
||
|
|
import pytest
|
||
|
|
from unittest.mock import MagicMock, patch, AsyncMock
|
||
|
|
from uuid import uuid4
|
||
|
|
from minio.error import S3Error
|
||
|
|
from trustgraph.librarian.blob_store import BlobStore
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Helpers
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def _make_blob_store():
|
||
|
|
"""Create a BlobStore with mocked Minio client."""
|
||
|
|
mock_minio = MagicMock()
|
||
|
|
with patch('trustgraph.librarian.blob_store.Minio', return_value=mock_minio):
|
||
|
|
# Prevent ensure_bucket from making network calls during init
|
||
|
|
with patch('trustgraph.librarian.blob_store.BlobStore.ensure_bucket'):
|
||
|
|
store = BlobStore(
|
||
|
|
endpoint="localhost:9000",
|
||
|
|
access_key="access",
|
||
|
|
secret_key="secret",
|
||
|
|
bucket_name="test-bucket"
|
||
|
|
)
|
||
|
|
return store, mock_minio
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Tests
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_add_success_no_retry():
|
||
|
|
store, mock_minio = _make_blob_store()
|
||
|
|
object_id = uuid4()
|
||
|
|
|
||
|
|
await store.add(object_id, b"data", "text/plain")
|
||
|
|
|
||
|
|
mock_minio.put_object.assert_called_once()
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_retry_recovery_on_transient_failure():
|
||
|
|
store, mock_minio = _make_blob_store()
|
||
|
|
store.base_delay = 0 # Disable delay for fast tests
|
||
|
|
|
||
|
|
# Fail twice, succeed third time
|
||
|
|
mock_minio.put_object.side_effect = [
|
||
|
|
Exception("Error 1"),
|
||
|
|
Exception("Error 2"),
|
||
|
|
MagicMock()
|
||
|
|
]
|
||
|
|
|
||
|
|
await store.add(uuid4(), b"data", "text/plain")
|
||
|
|
|
||
|
|
assert mock_minio.put_object.call_count == 3
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_retry_exhaustion_after_8_attempts():
|
||
|
|
store, mock_minio = _make_blob_store()
|
||
|
|
store.base_delay = 0
|
||
|
|
|
||
|
|
# Permanent failure
|
||
|
|
mock_minio.put_object.side_effect = Exception("Permanent failure")
|
||
|
|
|
||
|
|
with pytest.raises(Exception, match="Permanent failure"):
|
||
|
|
await store.add(uuid4(), b"data", "text/plain")
|
||
|
|
|
||
|
|
# Author requirement: exactly 8 attempts
|
||
|
|
assert mock_minio.put_object.call_count == 8
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_s3_error_triggers_retry():
|
||
|
|
store, mock_minio = _make_blob_store()
|
||
|
|
store.base_delay = 0
|
||
|
|
|
||
|
|
# Mock S3Error
|
||
|
|
s3_err = S3Error("code", "msg", "res", "req", "host", None)
|
||
|
|
mock_minio.get_object.side_effect = [s3_err, MagicMock()]
|
||
|
|
|
||
|
|
await store.get(uuid4())
|
||
|
|
|
||
|
|
assert mock_minio.get_object.call_count == 2
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_exponential_backoff_delays():
|
||
|
|
store, mock_minio = _make_blob_store()
|
||
|
|
# Use real base_delay to check math
|
||
|
|
store.base_delay = 0.25
|
||
|
|
|
||
|
|
# Correct method name is stat_object, not get_size
|
||
|
|
mock_minio.stat_object = MagicMock(side_effect=Exception("Wait"))
|
||
|
|
|
||
|
|
with patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
|
||
|
|
with pytest.raises(Exception):
|
||
|
|
await store.get_size(uuid4())
|
||
|
|
|
||
|
|
# Should have 7 sleep calls for 8 attempts
|
||
|
|
assert mock_sleep.call_count == 7
|
||
|
|
|
||
|
|
# Check actual sleep durations: 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0
|
||
|
|
sleep_args = [call[0][0] for call in mock_sleep.call_args_list]
|
||
|
|
assert sleep_args == [0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0]
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_runs_in_executor():
|
||
|
|
"""Verify that synchronous Minio calls are offloaded to an executor."""
|
||
|
|
store, mock_minio = _make_blob_store()
|
||
|
|
|
||
|
|
# Mock response object with .read() method
|
||
|
|
mock_response = MagicMock()
|
||
|
|
mock_response.read.return_value = b"result"
|
||
|
|
|
||
|
|
with patch('asyncio.get_event_loop') as mock_loop:
|
||
|
|
mock_loop_instance = MagicMock()
|
||
|
|
mock_loop.return_value = mock_loop_instance
|
||
|
|
mock_loop_instance.run_in_executor = AsyncMock(return_value=mock_response)
|
||
|
|
|
||
|
|
await store.get(uuid4())
|
||
|
|
|
||
|
|
mock_loop_instance.run_in_executor.assert_called_once()
|