397 lines
13 KiB
Python
397 lines
13 KiB
Python
"""Tests for job done callback functionality.
|
|
|
|
Tests the create_job_done_callback factory for handling background job
|
|
task completion including success, failure, and cancellation scenarios.
|
|
|
|
Sprint GAP-003: Error Handling Mismatches
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from noteflow.grpc.mixins._task_callbacks import create_job_done_callback
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Generator
|
|
|
|
# Test constants
|
|
JOB_ID_1 = "job-001"
|
|
"""Test job identifier for primary job."""
|
|
|
|
JOB_ID_2 = "job-002"
|
|
"""Test job identifier for secondary job."""
|
|
|
|
TASK_ERROR_MESSAGE = "Processing failed unexpectedly"
|
|
"""Error message for simulated task failures."""
|
|
|
|
|
|
@pytest.fixture
|
|
def tasks_dict() -> dict[str, asyncio.Task[None]]:
|
|
"""Create empty tasks dictionary for tracking active tasks."""
|
|
return {}
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_mark_failed() -> AsyncMock:
|
|
"""Create mock mark_failed coroutine function."""
|
|
return AsyncMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def event_loop_fixture() -> Generator[asyncio.AbstractEventLoop, None, None]:
|
|
"""Create and manage event loop for synchronous callback tests."""
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
yield loop
|
|
loop.close()
|
|
|
|
|
|
def _create_completed_task(loop: asyncio.AbstractEventLoop) -> asyncio.Task[None]:
|
|
"""Create a successfully completed task."""
|
|
|
|
async def successful() -> None:
|
|
pass
|
|
|
|
task = loop.create_task(successful())
|
|
loop.run_until_complete(task)
|
|
return task
|
|
|
|
|
|
def _create_failed_task(
|
|
loop: asyncio.AbstractEventLoop, error_msg: str = TASK_ERROR_MESSAGE
|
|
) -> asyncio.Task[None]:
|
|
"""Create a task that failed with ValueError."""
|
|
|
|
async def failing() -> None:
|
|
raise ValueError(error_msg)
|
|
|
|
task = loop.create_task(failing())
|
|
with contextlib.suppress(ValueError):
|
|
loop.run_until_complete(task)
|
|
return task
|
|
|
|
|
|
def _create_cancelled_task(loop: asyncio.AbstractEventLoop) -> asyncio.Task[None]:
|
|
"""Create a cancelled task without using sleep."""
|
|
|
|
async def cancellable() -> None:
|
|
# Use an Event instead of sleep to make task cancellable
|
|
event = asyncio.Event()
|
|
await event.wait()
|
|
|
|
task = loop.create_task(cancellable())
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
loop.run_until_complete(task)
|
|
return task
|
|
|
|
|
|
def _run_pending_callbacks(loop: asyncio.AbstractEventLoop) -> None:
|
|
"""Run any pending callbacks on the loop."""
|
|
loop.run_until_complete(asyncio.ensure_future(asyncio.sleep(0), loop=loop))
|
|
|
|
|
|
class TestCallbackCreation:
|
|
"""Tests for create_job_done_callback factory function."""
|
|
|
|
def test_returns_callable(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
) -> None:
|
|
"""Verify factory returns a callable callback."""
|
|
callback = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
|
|
assert callable(callback), "Factory should return a callable"
|
|
|
|
def test_callback_accepts_task_argument(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
event_loop_fixture: asyncio.AbstractEventLoop,
|
|
) -> None:
|
|
"""Verify callback signature accepts asyncio.Task."""
|
|
task = _create_completed_task(event_loop_fixture)
|
|
callback = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
|
|
callback(task) # Should not raise
|
|
|
|
|
|
class TestTaskDictCleanupOnSuccess:
|
|
"""Tests for tasks_dict cleanup on successful completion."""
|
|
|
|
def test_removes_job_on_success(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
event_loop_fixture: asyncio.AbstractEventLoop,
|
|
) -> None:
|
|
"""Verify job is removed from tasks_dict on successful completion."""
|
|
task = _create_completed_task(event_loop_fixture)
|
|
tasks_dict[JOB_ID_1] = task
|
|
|
|
callback = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
callback(task)
|
|
|
|
assert JOB_ID_1 not in tasks_dict, "Job should be removed from tasks_dict after completion"
|
|
|
|
|
|
class TestTaskDictCleanupOnFailure:
|
|
"""Tests for tasks_dict cleanup on task failure."""
|
|
|
|
def test_removes_job_on_failure(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
event_loop_fixture: asyncio.AbstractEventLoop,
|
|
) -> None:
|
|
"""Verify job is removed from tasks_dict on task failure."""
|
|
task = _create_failed_task(event_loop_fixture)
|
|
tasks_dict[JOB_ID_1] = task
|
|
|
|
callback = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
callback(task)
|
|
|
|
assert JOB_ID_1 not in tasks_dict, "Job should be removed from tasks_dict after failure"
|
|
|
|
|
|
class TestTaskDictMissingJob:
|
|
"""Tests for handling missing job in tasks_dict."""
|
|
|
|
def test_handles_missing_job_gracefully(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
event_loop_fixture: asyncio.AbstractEventLoop,
|
|
) -> None:
|
|
"""Verify callback handles case where job is not in tasks_dict."""
|
|
task = _create_completed_task(event_loop_fixture)
|
|
# Intentionally NOT adding to tasks_dict
|
|
|
|
callback = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
|
|
callback(task) # Should not raise
|
|
|
|
|
|
class TestSuccessfulTaskNoMarkFailed:
|
|
"""Tests verifying mark_failed is not called on success."""
|
|
|
|
def test_mark_failed_not_called_on_success(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
event_loop_fixture: asyncio.AbstractEventLoop,
|
|
) -> None:
|
|
"""Verify mark_failed is not called when task succeeds."""
|
|
task = _create_completed_task(event_loop_fixture)
|
|
|
|
callback = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
callback(task)
|
|
|
|
mock_mark_failed.assert_not_called()
|
|
|
|
|
|
class TestCancelledTaskNoMarkFailed:
|
|
"""Tests verifying mark_failed is not called on cancellation."""
|
|
|
|
def test_mark_failed_not_called_on_cancel(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
event_loop_fixture: asyncio.AbstractEventLoop,
|
|
) -> None:
|
|
"""Verify mark_failed is not called when task is cancelled."""
|
|
task = _create_cancelled_task(event_loop_fixture)
|
|
|
|
callback = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
callback(task)
|
|
|
|
mock_mark_failed.assert_not_called()
|
|
|
|
|
|
class TestCancelledTaskCleanup:
|
|
"""Tests for cancelled task cleanup."""
|
|
|
|
def test_removes_cancelled_job_from_tasks_dict(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
event_loop_fixture: asyncio.AbstractEventLoop,
|
|
) -> None:
|
|
"""Verify cancelled job is removed from tasks_dict."""
|
|
task = _create_cancelled_task(event_loop_fixture)
|
|
tasks_dict[JOB_ID_1] = task
|
|
|
|
callback = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
callback(task)
|
|
|
|
assert JOB_ID_1 not in tasks_dict, "Cancelled job should be removed from tasks_dict"
|
|
|
|
|
|
class TestFailedTaskSchedulesMarkFailed:
|
|
"""Tests for mark_failed scheduling on task failure."""
|
|
|
|
def test_schedules_mark_failed_on_exception(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
event_loop_fixture: asyncio.AbstractEventLoop,
|
|
) -> None:
|
|
"""Verify mark_failed is scheduled when task raises exception."""
|
|
task = _create_failed_task(event_loop_fixture)
|
|
|
|
callback = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
callback(task)
|
|
_run_pending_callbacks(event_loop_fixture)
|
|
|
|
mock_mark_failed.assert_called_once()
|
|
|
|
|
|
class TestMarkFailedReceivesJobId:
|
|
"""Tests for mark_failed receiving correct job_id."""
|
|
|
|
def test_receives_correct_job_id(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
event_loop_fixture: asyncio.AbstractEventLoop,
|
|
) -> None:
|
|
"""Verify mark_failed is called with correct job_id."""
|
|
task = _create_failed_task(event_loop_fixture)
|
|
|
|
callback = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
callback(task)
|
|
_run_pending_callbacks(event_loop_fixture)
|
|
|
|
call_args = mock_mark_failed.call_args
|
|
assert call_args[0][0] == JOB_ID_1, f"mark_failed should receive job_id '{JOB_ID_1}'"
|
|
|
|
|
|
class TestMarkFailedReceivesErrorMessage:
|
|
"""Tests for mark_failed receiving error message."""
|
|
|
|
def test_receives_error_message(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
event_loop_fixture: asyncio.AbstractEventLoop,
|
|
) -> None:
|
|
"""Verify mark_failed is called with error message string."""
|
|
task = _create_failed_task(event_loop_fixture)
|
|
|
|
callback = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
callback(task)
|
|
_run_pending_callbacks(event_loop_fixture)
|
|
|
|
call_args = mock_mark_failed.call_args
|
|
assert TASK_ERROR_MESSAGE in call_args[0][1], (
|
|
f"mark_failed should receive error message containing '{TASK_ERROR_MESSAGE}'"
|
|
)
|
|
|
|
|
|
class TestVariousExceptionTypes:
|
|
"""Tests for handling various exception types."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("exception_type", "error_prefix"),
|
|
[
|
|
pytest.param(ValueError, "ValueError", id="value_error"),
|
|
pytest.param(RuntimeError, "RuntimeError", id="runtime_error"),
|
|
pytest.param(TypeError, "TypeError", id="type_error"),
|
|
],
|
|
)
|
|
def test_handles_exception_type(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
event_loop_fixture: asyncio.AbstractEventLoop,
|
|
exception_type: type[Exception],
|
|
error_prefix: str,
|
|
) -> None:
|
|
"""Verify callback handles various exception types correctly."""
|
|
error_msg = f"{error_prefix} test error"
|
|
|
|
async def failing() -> None:
|
|
raise exception_type(error_msg)
|
|
|
|
task = event_loop_fixture.create_task(failing())
|
|
with contextlib.suppress(exception_type):
|
|
event_loop_fixture.run_until_complete(task)
|
|
|
|
callback = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
callback(task)
|
|
_run_pending_callbacks(event_loop_fixture)
|
|
|
|
assert mock_mark_failed.called, f"mark_failed should be called for {error_prefix}"
|
|
|
|
|
|
class TestClosedLoopHandling:
|
|
"""Tests for handling RuntimeError when event loop is closed."""
|
|
|
|
def test_does_not_raise_on_closed_loop(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
) -> None:
|
|
"""Verify callback does not raise when loop is closed.
|
|
|
|
Note: mark_failed IS called to create the coroutine, but the
|
|
task scheduling fails. The callback gracefully handles this.
|
|
"""
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
task = _create_failed_task(loop)
|
|
loop.close()
|
|
|
|
callback = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
|
|
# Key assertion: should not raise even with closed loop
|
|
callback(task)
|
|
|
|
|
|
class TestMultipleJobsIndependence:
|
|
"""Tests for multiple job independence."""
|
|
|
|
def test_first_callback_removes_only_first_job(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
event_loop_fixture: asyncio.AbstractEventLoop,
|
|
) -> None:
|
|
"""Verify first callback only removes first job."""
|
|
task1 = _create_completed_task(event_loop_fixture)
|
|
task2 = _create_completed_task(event_loop_fixture)
|
|
tasks_dict[JOB_ID_1] = task1
|
|
tasks_dict[JOB_ID_2] = task2
|
|
|
|
callback1 = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
callback1(task1)
|
|
|
|
assert JOB_ID_1 not in tasks_dict, "Job 1 should be removed"
|
|
assert JOB_ID_2 in tasks_dict, "Job 2 should still be present"
|
|
|
|
def test_second_callback_removes_second_job(
|
|
self,
|
|
tasks_dict: dict[str, asyncio.Task[None]],
|
|
mock_mark_failed: AsyncMock,
|
|
event_loop_fixture: asyncio.AbstractEventLoop,
|
|
) -> None:
|
|
"""Verify second callback removes second job."""
|
|
task1 = _create_completed_task(event_loop_fixture)
|
|
task2 = _create_completed_task(event_loop_fixture)
|
|
tasks_dict[JOB_ID_1] = task1
|
|
tasks_dict[JOB_ID_2] = task2
|
|
|
|
callback1 = create_job_done_callback(JOB_ID_1, tasks_dict, mock_mark_failed)
|
|
callback2 = create_job_done_callback(JOB_ID_2, tasks_dict, mock_mark_failed)
|
|
callback1(task1)
|
|
callback2(task2)
|
|
|
|
assert JOB_ID_2 not in tasks_dict, "Job 2 should be removed"
|