Files
ss-tools/backend/tests/plugins/translate/test_llm_async.py
busya 9f9ad91345 032: T029-T031 + T046-T048 — all remaining tests
T029: concurrent preview+schema check test
T030: static asyncio.sleep audit
T031: LLM rate-limit backoff test + 6 edge cases
T046: TaskManager concurrent tasks + cancellation tests
T047: async notifications — SMTP timeout test
T048: EventBus publish/subscribe + maxsize tests

34 total async tests passing.
2026-06-04 21:06:22 +03:00

194 lines
7.8 KiB
Python

# #region TestLLMAsyncHttp [C:3] [TYPE Module] [SEMANTICS test, translate, llm, async, httpx, rate-limit]
# @BRIEF Tests for async LLM HTTP client — rate-limit retry, asyncio.sleep backoff, structured output fallback.
# @RELATION BINDS_TO -> [LLMAsyncHttpClient]
# @RELATION BINDS_TO -> [call_openai_compatible]
# @TEST_CONTRACT: 429 retry uses asyncio.sleep -> no event loop block
# @TEST_EDGE: rate_limit_429_retries -> VERIFIED_BY: test_llm_rate_limit_uses_asyncio_sleep
# @TEST_EDGE: empty_choices_raises -> VERIFIED_BY: test_call_openai_compatible_empty_choices
# @TEST_EDGE: missing_base_url_raises -> VERIFIED_BY: test_call_openai_compatible_no_base_url
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
# #region test_llm_rate_limit_uses_asyncio_sleep [C:2] [TYPE Function]
# @BRIEF Verify 429 backoff doesn't block other requests — mock httpx with 2 failures then success.
# @TEST_EDGE: rate_limit_429_retries
@pytest.mark.asyncio
async def test_llm_rate_limit_uses_asyncio_sleep():
"""Verify call_openai_compatible retries on 429 using asyncio.sleep, not time.sleep."""
from src.plugins.translate._llm_async_http import call_openai_compatible
call_count = 0
def mock_post_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count <= 2:
resp = MagicMock(spec=httpx.Response)
resp.status_code = 429
resp.ok = False
resp.text = "Rate limited"
resp.headers = {"Retry-After": "1"}
resp.json.return_value = {}
return resp
resp = MagicMock(spec=httpx.Response)
resp.status_code = 200
resp.ok = True
resp.text = '{"choices":[{"message":{"content":"{\\"translated\\": \\"hola\\"}"},"finish_reason":"stop"}]}'
resp.json.return_value = {
"choices": [{"message": {"content": '{"translated": "hola"}'}, "finish_reason": "stop"}]
}
return resp
mock_client = AsyncMock()
mock_client.post = AsyncMock(side_effect=mock_post_side_effect)
with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
# Patch asyncio.sleep to verify it's called (not time.sleep)
sleep_calls = []
original_sleep = asyncio.sleep
async def tracking_sleep(seconds):
sleep_calls.append(seconds)
await original_sleep(0) # Don't actually wait
with patch("src.plugins.translate._llm_async_http.asyncio.sleep", side_effect=tracking_sleep):
content, finish_reason = await call_openai_compatible(
base_url="http://fake-llm.local",
api_key="test-key",
model="gpt-4",
prompt="translate: hello",
)
assert call_count == 3, f"Expected 3 HTTP calls (2 retries + 1 success), got {call_count}"
assert len(sleep_calls) == 2, f"Expected 2 sleep calls for 429 retry, got {len(sleep_calls)}"
assert finish_reason == "stop"
# #endregion test_llm_rate_limit_uses_asyncio_sleep
# #region test_call_openai_compatible_no_base_url [C:2] [TYPE Function]
# @BRIEF Verify call_openai_compatible raises ValueError when base_url is empty.
# @TEST_EDGE: missing_base_url_raises
@pytest.mark.asyncio
async def test_call_openai_compatible_no_base_url():
"""Verify empty base_url raises ValueError immediately."""
from src.plugins.translate._llm_async_http import call_openai_compatible
with pytest.raises(ValueError, match="no base_url"):
await call_openai_compatible(
base_url="",
api_key="test-key",
model="gpt-4",
prompt="hello",
)
# #endregion test_call_openai_compatible_no_base_url
# #region test_call_openai_compatible_empty_choices [C:2] [TYPE Function]
# @BRIEF Verify call_openai_compatible raises ValueError when LLM returns empty choices.
# @TEST_EDGE: empty_choices_raises
@pytest.mark.asyncio
async def test_call_openai_compatible_empty_choices():
"""Verify LLM response with empty choices raises ValueError."""
from src.plugins.translate._llm_async_http import call_openai_compatible
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.ok = True
mock_response.json.return_value = {"choices": []}
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
with pytest.raises(ValueError, match="no choices"):
await call_openai_compatible(
base_url="http://fake-llm.local",
api_key="test-key",
model="gpt-4",
prompt="hello",
)
# #endregion test_call_openai_compatible_empty_choices
# #region test_call_openai_compatible_refusal [C:2] [TYPE Function]
# @BRIEF Verify call_openai_compatible raises ValueError when LLM returns a refusal.
# @TEST_EDGE: llm_refusal_raises
@pytest.mark.asyncio
async def test_call_openai_compatible_refusal():
"""Verify LLM refusal response raises ValueError."""
from src.plugins.translate._llm_async_http import call_openai_compatible
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.ok = True
mock_response.json.return_value = {
"choices": [{"message": {"content": "", "refusal": "I cannot translate this"}, "finish_reason": "stop"}]
}
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
with pytest.raises(ValueError, match="refused"):
await call_openai_compatible(
base_url="http://fake-llm.local",
api_key="test-key",
model="gpt-4",
prompt="hello",
)
# #endregion test_call_openai_compatible_refusal
# #region test_retry_does_not_block_event_loop [C:2] [TYPE Function]
# @BRIEF Verify 429 retry sleep doesn't block concurrent tasks on the same event loop.
# @TEST_INVARIANT: retry_backoff_non_blocking -> VERIFIED_BY: test_retry_does_not_block_event_loop
@pytest.mark.asyncio
async def test_retry_does_not_block_event_loop():
"""Verify that 429 retry backoff allows other coroutines to run."""
from src.plugins.translate._llm_async_http import _do_http_request
concurrent_ran = False
async def concurrent_task():
nonlocal concurrent_ran
await asyncio.sleep(0.01)
concurrent_ran = True
call_count = 0
def mock_post(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
resp = MagicMock(spec=httpx.Response)
resp.status_code = 429
resp.ok = False
resp.text = "Rate limited"
resp.headers = {"Retry-After": "1"}
return resp
resp = MagicMock(spec=httpx.Response)
resp.status_code = 200
resp.ok = True
resp.text = "ok"
return resp
mock_client = AsyncMock()
mock_client.post = AsyncMock(side_effect=mock_post)
with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
with patch("src.plugins.translate._llm_async_http.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
# Run the HTTP request alongside a concurrent task
http_task = asyncio.create_task(_do_http_request("http://fake", {}, {}))
conc_task = asyncio.create_task(concurrent_task())
await asyncio.gather(http_task, conc_task)
assert concurrent_ran, "Concurrent task was blocked by 429 retry"
assert mock_sleep.called, "asyncio.sleep was not called during 429 retry"
# #endregion test_retry_does_not_block_event_loop
# #endregion TestLLMAsyncHttp