- Remove structlog dependency from backend/pyproject.toml - Add app.utils.logging_compat shim for keyword-arg logging API - Add app.utils.json_formatter for JSON log output with extra fields - Update all backend modules to use logging_compat.get_logger() - Update docstrings in log_sanitizer.py and json_formatter.py - Update test comment in test_async_utils.py - Record 406 failing tests in Docs/Tasks.md for tracking
116 lines
3.4 KiB
Python
116 lines
3.4 KiB
Python
"""Tests for async_utils."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from app.utils.async_utils import logged_task, run_blocking
|
|
|
|
|
|
async def test_run_blocking_executes_callable_in_thread() -> None:
|
|
"""run_blocking should execute the provided callable and return its result."""
|
|
|
|
def blocking_add(x: int, y: int) -> int:
|
|
return x + y
|
|
|
|
result = await run_blocking(blocking_add, 3, 4)
|
|
assert result == 7
|
|
|
|
|
|
async def test_run_blocking_accepts_custom_executor() -> None:
|
|
"""run_blocking should use a provided executor when one is passed."""
|
|
|
|
def blocking_value() -> str:
|
|
return "ok"
|
|
|
|
executor = ThreadPoolExecutor(max_workers=1)
|
|
try:
|
|
result = await run_blocking(blocking_value, executor=executor)
|
|
assert result == "ok"
|
|
finally:
|
|
executor.shutdown(wait=True)
|
|
|
|
|
|
async def test_run_blocking_does_not_block_event_loop() -> None:
|
|
"""A blocking callable should not stall other async tasks."""
|
|
|
|
def sleep_block() -> str:
|
|
time.sleep(0.05)
|
|
return "done"
|
|
|
|
task = asyncio.create_task(run_blocking(sleep_block))
|
|
# Yield control to ensure the event loop can run other tasks while the
|
|
# blocking work executes in the thread pool.
|
|
await asyncio.sleep(0)
|
|
result = await task
|
|
assert result == "done"
|
|
|
|
|
|
async def test_logged_task_awaits_coroutine() -> None:
|
|
"""logged_task should await and complete the coroutine."""
|
|
|
|
async def dummy_coro() -> str:
|
|
await asyncio.sleep(0)
|
|
return "result"
|
|
|
|
with mock.patch("app.utils.async_utils.log") as mock_log:
|
|
await logged_task(dummy_coro(), "test_task")
|
|
mock_log.exception.assert_not_called()
|
|
|
|
|
|
async def test_logged_task_catches_exception_and_logs() -> None:
|
|
"""logged_task should catch exceptions and log them with task_name context."""
|
|
|
|
class CustomError(Exception):
|
|
pass
|
|
|
|
async def failing_coro() -> None:
|
|
raise CustomError("task failed")
|
|
|
|
with mock.patch("app.utils.async_utils.log") as mock_log:
|
|
await logged_task(failing_coro(), "failing_task")
|
|
mock_log.exception.assert_called_once_with(
|
|
"background_task_failed",
|
|
task_name="failing_task",
|
|
)
|
|
|
|
|
|
async def test_logged_task_with_asyncio_create_task() -> None:
|
|
"""logged_task should work correctly when wrapped with asyncio.create_task."""
|
|
|
|
results: list[str] = []
|
|
|
|
async def background_work() -> None:
|
|
await asyncio.sleep(0.01)
|
|
results.append("done")
|
|
|
|
with mock.patch("app.utils.async_utils.log"):
|
|
task = asyncio.create_task(
|
|
logged_task(background_work(), "bg_task"),
|
|
name="bg_task",
|
|
)
|
|
await task
|
|
assert results == ["done"]
|
|
|
|
|
|
async def test_logged_task_preserves_exception_info() -> None:
|
|
"""logged_task should preserve traceback when logging the exception."""
|
|
|
|
async def failing_coro() -> None:
|
|
raise ValueError("original error message")
|
|
|
|
with mock.patch("app.utils.async_utils.log") as mock_log:
|
|
await logged_task(failing_coro(), "test_task")
|
|
mock_log.exception.assert_called_once()
|
|
# Verify the exception context is logged (exception captures
|
|
# the traceback automatically)
|
|
args, kwargs = mock_log.exception.call_args
|
|
assert args[0] == "background_task_failed"
|
|
assert kwargs["task_name"] == "test_task"
|
|
|