Files
noteflow/tests/grpc/test_task_callbacks.py
Travis Vasceannie d8090a98e8
Some checks failed
CI / test-typescript (push) Has been cancelled
CI / test-rust (push) Has been cancelled
CI / test-python (push) Has been cancelled
ci/cd fixes
2026-01-26 00:28:15 +00:00

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"