test: 6 agents — +52 test files across core, task_manager, translate routes, git/storage/migration routes, dataset_review deps/routes, settings. Fixed 4 failures
This commit is contained in:
613
backend/tests/core/task_manager/test_event_bus.py
Normal file
613
backend/tests/core/task_manager/test_event_bus.py
Normal file
@@ -0,0 +1,613 @@
|
||||
# #region Test.Core.TaskManager.EventBus [C:3] [TYPE Module] [SEMANTICS test,eventbus,async,pubsub]
|
||||
# @BRIEF Verify EventBus contracts — buffering, flush, subscribe/broadcast, persistence delegation.
|
||||
# @RELATION BINDS_TO -> [EventBus]
|
||||
# @TEST_EDGE: level_filter -> DEBUG logs skipped when filter is INFO
|
||||
# @TEST_EDGE: queue_full -> Subscriber queue full drops entries without raising
|
||||
# @TEST_EDGE: flush_error -> Flush re-queues logs on persistence failure
|
||||
# @TEST_EDGE: no_subscriber -> Broadcast with no subscribers is no-op
|
||||
# @TEST_EDGE: empty_flush -> Flush with empty buffer is no-op
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
|
||||
from src.core.task_manager.event_bus import EventBus, QUEUE_MAXSIZE
|
||||
from src.core.task_manager.models import LogEntry, LogFilter, LogStats, TaskStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_persistence():
|
||||
"""Hardcoded fixture: mock TaskLogPersistenceService."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_bus(mock_persistence):
|
||||
"""Hardcoded fixture: EventBus with mocked persistence."""
|
||||
return EventBus(mock_persistence)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Init
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusInit:
|
||||
"""Verify EventBus.__init__ @POST: ready to accept logs, empty structures."""
|
||||
|
||||
def test_init_empty_structures(self, event_bus):
|
||||
assert event_bus.subscribers == {}
|
||||
assert event_bus._status_subscribers == {}
|
||||
assert event_bus._task_event_subscribers == []
|
||||
assert event_bus._maintenance_subscribers == []
|
||||
assert event_bus._log_buffer == {}
|
||||
assert event_bus._flusher_task is None
|
||||
assert isinstance(event_bus._flusher_stop_event, asyncio.Event)
|
||||
|
||||
def test_init_persistence_stored(self, event_bus, mock_persistence):
|
||||
assert event_bus.log_persistence_service is mock_persistence
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Start / Stop
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusStartStop:
|
||||
"""Verify start/stop @POST contracts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_creates_async_task(self, event_bus):
|
||||
event_bus.start()
|
||||
assert event_bus._flusher_task is not None
|
||||
assert not event_bus._flusher_task.done()
|
||||
await event_bus.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_idempotent(self, event_bus):
|
||||
event_bus.start()
|
||||
first_task = event_bus._flusher_task
|
||||
event_bus.start()
|
||||
assert event_bus._flusher_task is first_task
|
||||
await event_bus.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_cancels_flusher(self, event_bus):
|
||||
event_bus.start()
|
||||
await event_bus.stop()
|
||||
assert event_bus._flusher_task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_without_start(self, event_bus):
|
||||
await event_bus.stop() # Must not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_replaces_done_task(self, event_bus):
|
||||
"""If previous flusher task is done, start() creates a new one."""
|
||||
# Create a done task
|
||||
async def done_task():
|
||||
pass
|
||||
event_bus._flusher_task = asyncio.create_task(done_task())
|
||||
await event_bus._flusher_task # wait for completion
|
||||
assert event_bus._flusher_task.done()
|
||||
|
||||
event_bus.start() # should create new task since old one is done
|
||||
assert event_bus._flusher_task is not None
|
||||
assert not event_bus._flusher_task.done()
|
||||
await event_bus.stop()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# add_log
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusAddLog:
|
||||
"""Verify add_log @POST: buffer, subscriber dispatch, level filtering."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_appends_to_buffer(self, event_bus):
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=True):
|
||||
await event_bus.add_log("t1", "INFO", "hello", source="test")
|
||||
assert "t1" in event_bus._log_buffer
|
||||
assert len(event_bus._log_buffer["t1"]) == 1
|
||||
assert event_bus._log_buffer["t1"][0].message == "hello"
|
||||
assert event_bus._log_buffer["t1"][0].source == "test"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_skipped_by_level_filter(self, event_bus):
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=False):
|
||||
await event_bus.add_log("t1", "DEBUG", "debug msg")
|
||||
assert "t1" not in event_bus._log_buffer
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_with_task_logs_list(self, event_bus):
|
||||
task_logs = []
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=True):
|
||||
await event_bus.add_log("t1", "INFO", "msg", source="test", task_logs_list=task_logs)
|
||||
assert len(task_logs) == 1
|
||||
assert task_logs[0].message == "msg"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_notifies_subscribers(self, event_bus):
|
||||
queue = asyncio.Queue()
|
||||
event_bus.subscribers["t1"] = [queue]
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=True):
|
||||
await event_bus.add_log("t1", "INFO", "notify msg")
|
||||
received = await asyncio.wait_for(queue.get(), timeout=0.5)
|
||||
assert received.message == "notify msg"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_with_subscriber_consumes(self, event_bus):
|
||||
"""Subscriber receives log entry via queue."""
|
||||
queue = asyncio.Queue()
|
||||
event_bus.subscribers["t1"] = [queue]
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=True):
|
||||
await event_bus.add_log("t1", "INFO", "consume me")
|
||||
received = await asyncio.wait_for(queue.get(), timeout=0.5)
|
||||
assert received.message == "consume me"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_subscriber_queue_full_blocks(self, event_bus):
|
||||
"""With a full asyncio.Queue, await queue.put() blocks (QueueFull not raised by put)."""
|
||||
full_queue = asyncio.Queue(maxsize=1)
|
||||
await full_queue.put("fill")
|
||||
event_bus.subscribers["t1"] = [full_queue]
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=True):
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
event_bus.add_log("t1", "INFO", "block me"),
|
||||
timeout=0.3,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_multiple_buffers_different_tasks(self, event_bus):
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=True):
|
||||
await event_bus.add_log("t1", "INFO", "msg1")
|
||||
await event_bus.add_log("t2", "INFO", "msg2")
|
||||
assert len(event_bus._log_buffer) == 2
|
||||
assert len(event_bus._log_buffer["t1"]) == 1
|
||||
assert len(event_bus._log_buffer["t2"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_metadata_and_context(self, event_bus):
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=True):
|
||||
await event_bus.add_log(
|
||||
"t1", "ERROR", "fail",
|
||||
metadata={"code": 500},
|
||||
context={"env": "prod"},
|
||||
)
|
||||
entry = event_bus._log_buffer["t1"][0]
|
||||
assert entry.metadata == {"code": 500}
|
||||
assert entry.context == {"env": "prod"}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Subscribe / Unsubscribe — Logs
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusLogSubscribers:
|
||||
"""Verify subscribe_logs/unsubscribe_logs @POST."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_logs_creates_queue(self, event_bus):
|
||||
queue = await event_bus.subscribe_logs("t1")
|
||||
assert isinstance(queue, asyncio.Queue)
|
||||
assert queue.maxsize == QUEUE_MAXSIZE
|
||||
assert "t1" in event_bus.subscribers
|
||||
assert queue in event_bus.subscribers["t1"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_logs_removes_queue(self, event_bus):
|
||||
queue = await event_bus.subscribe_logs("t1")
|
||||
event_bus.unsubscribe_logs("t1", queue)
|
||||
assert "t1" not in event_bus.subscribers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_nonexistent_task(self, event_bus):
|
||||
queue = asyncio.Queue()
|
||||
event_bus.unsubscribe_logs("no-such-task", queue) # must not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_nonexistent_queue(self, event_bus):
|
||||
await event_bus.subscribe_logs("t1")
|
||||
other_queue = asyncio.Queue()
|
||||
event_bus.unsubscribe_logs("t1", other_queue) # not in list, must not raise
|
||||
assert len(event_bus.subscribers["t1"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_logs_multiple_queues(self, event_bus):
|
||||
q1 = await event_bus.subscribe_logs("t1")
|
||||
q2 = await event_bus.subscribe_logs("t1")
|
||||
assert len(event_bus.subscribers["t1"]) == 2
|
||||
event_bus.unsubscribe_logs("t1", q1)
|
||||
assert len(event_bus.subscribers["t1"]) == 1
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Subscribe / Unsubscribe — Status
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusStatusSubscribers:
|
||||
"""Verify subscribe_status/unsubscribe_status @POST."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_status_creates_queue(self, event_bus):
|
||||
queue = await event_bus.subscribe_status("t1")
|
||||
assert isinstance(queue, asyncio.Queue)
|
||||
assert "t1" in event_bus._status_subscribers
|
||||
assert queue in event_bus._status_subscribers["t1"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_status_removes_queue(self, event_bus):
|
||||
queue = await event_bus.subscribe_status("t1")
|
||||
event_bus.unsubscribe_status("t1", queue)
|
||||
assert "t1" not in event_bus._status_subscribers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_status_nonexistent(self, event_bus):
|
||||
queue = asyncio.Queue()
|
||||
event_bus.unsubscribe_status("no-such", queue)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_status_not_in_list(self, event_bus):
|
||||
await event_bus.subscribe_status("t1")
|
||||
other = asyncio.Queue()
|
||||
event_bus.unsubscribe_status("t1", other)
|
||||
assert len(event_bus._status_subscribers["t1"]) == 1
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Subscribe / Unsubscribe — Maintenance
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusMaintenanceSubscribers:
|
||||
"""Verify subscribe/unsubscribe_maintenance_events @POST."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_maintenance(self, event_bus):
|
||||
queue = await event_bus.subscribe_maintenance_events()
|
||||
assert queue in event_bus._maintenance_subscribers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_maintenance_multiple(self, event_bus):
|
||||
q1 = await event_bus.subscribe_maintenance_events()
|
||||
q2 = await event_bus.subscribe_maintenance_events()
|
||||
assert len(event_bus._maintenance_subscribers) == 2
|
||||
|
||||
def test_unsubscribe_maintenance(self, event_bus):
|
||||
queue = asyncio.Queue()
|
||||
event_bus._maintenance_subscribers.append(queue)
|
||||
event_bus.unsubscribe_maintenance_events(queue)
|
||||
assert queue not in event_bus._maintenance_subscribers
|
||||
|
||||
def test_unsubscribe_maintenance_not_found(self, event_bus):
|
||||
queue = asyncio.Queue()
|
||||
event_bus.unsubscribe_maintenance_events(queue) # must not raise
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Subscribe / Unsubscribe — Global Task Events
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusTaskEvents:
|
||||
"""Verify subscribe/unsubscribe_task_events @POST."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_task_events(self, event_bus):
|
||||
queue = await event_bus.subscribe_task_events()
|
||||
assert queue in event_bus._task_event_subscribers
|
||||
|
||||
def test_unsubscribe_task_events(self, event_bus):
|
||||
queue = asyncio.Queue()
|
||||
event_bus._task_event_subscribers.append(queue)
|
||||
event_bus.unsubscribe_task_events(queue)
|
||||
assert queue not in event_bus._task_event_subscribers
|
||||
|
||||
def test_unsubscribe_task_events_not_found(self, event_bus):
|
||||
queue = asyncio.Queue()
|
||||
event_bus.unsubscribe_task_events(queue) # must not raise
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Broadcast
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusBroadcast:
|
||||
"""Verify broadcast_status @POST and broadcast_maintenance_event @POST."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_status_to_per_task(self, event_bus):
|
||||
queue = await event_bus.subscribe_status("t1")
|
||||
task_dict = {"id": "t1", "status": "RUNNING"}
|
||||
await event_bus.broadcast_status("t1", task_dict)
|
||||
event = await asyncio.wait_for(queue.get(), timeout=0.5)
|
||||
assert event["type"] == "task_status"
|
||||
assert event["task_id"] == "t1"
|
||||
assert event["task"] == task_dict
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_status_to_global(self, event_bus):
|
||||
global_queue = await event_bus.subscribe_task_events()
|
||||
await event_bus.broadcast_status("t1", {"id": "t1"})
|
||||
event = await asyncio.wait_for(global_queue.get(), timeout=0.5)
|
||||
assert event["type"] == "task_status"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_status_queue_full_blocks(self, event_bus):
|
||||
full = asyncio.Queue(maxsize=1)
|
||||
await full.put("fill")
|
||||
event_bus._status_subscribers["t1"] = [full]
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
event_bus.broadcast_status("t1", {"id": "t1"}),
|
||||
timeout=0.3,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_status_global_queue_full_blocks(self, event_bus):
|
||||
full = asyncio.Queue(maxsize=1)
|
||||
await full.put("fill")
|
||||
event_bus._task_event_subscribers = [full]
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
event_bus.broadcast_status("t1", {"id": "t1"}),
|
||||
timeout=0.3,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_maintenance_event(self, event_bus):
|
||||
queue = await event_bus.subscribe_maintenance_events()
|
||||
event = {"type": "maintenance", "status": "active"}
|
||||
await event_bus.broadcast_maintenance_event(event)
|
||||
received = await asyncio.wait_for(queue.get(), timeout=0.5)
|
||||
assert received == event
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_maintenance_queue_full_blocks(self, event_bus):
|
||||
full = asyncio.Queue(maxsize=1)
|
||||
await full.put("fill")
|
||||
event_bus._maintenance_subscribers = [full]
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
event_bus.broadcast_maintenance_event({"type": "test"}),
|
||||
timeout=0.3,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_status_no_subscribers(self, event_bus):
|
||||
await event_bus.broadcast_status("orphan", {"id": "orphan"})
|
||||
# no subscribers — no-op, must not raise
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Flush
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusFlush:
|
||||
"""Verify flush_task_logs and _flush_logs @POST contracts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_task_logs_success(self, event_bus, mock_persistence):
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="flush me")]
|
||||
await event_bus.flush_task_logs("t1")
|
||||
mock_persistence.add_logs.assert_called_once()
|
||||
assert "t1" not in event_bus._log_buffer
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_task_logs_empty(self, event_bus, mock_persistence):
|
||||
await event_bus.flush_task_logs("t1")
|
||||
mock_persistence.add_logs.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_task_logs_error(self, event_bus, mock_persistence):
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="fail")]
|
||||
mock_persistence.add_logs.side_effect = Exception("DB err")
|
||||
await event_bus.flush_task_logs("t1")
|
||||
# flush_task_logs does NOT re-queue on failure
|
||||
assert "t1" not in event_bus._log_buffer
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_logs_success(self, event_bus, mock_persistence):
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="a")]
|
||||
event_bus._log_buffer["t2"] = [LogEntry(message="b")]
|
||||
await event_bus._flush_logs()
|
||||
assert mock_persistence.add_logs.call_count == 2
|
||||
assert event_bus._log_buffer == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_logs_requeues_on_error(self, event_bus, mock_persistence):
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="requeue")]
|
||||
mock_persistence.add_logs.side_effect = Exception("DB err")
|
||||
await event_bus._flush_logs()
|
||||
# _flush_logs re-queues logs on failure
|
||||
assert "t1" in event_bus._log_buffer
|
||||
assert len(event_bus._log_buffer["t1"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_logs_empty(self, event_bus, mock_persistence):
|
||||
await event_bus._flush_logs()
|
||||
mock_persistence.add_logs.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_logs_mixed_success_failure(self, event_bus, mock_persistence):
|
||||
"""One task fails, another succeeds."""
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="ok")]
|
||||
event_bus._log_buffer["t2"] = [LogEntry(message="broken")]
|
||||
|
||||
def side_effect(task_id, logs):
|
||||
if task_id == "t2":
|
||||
raise Exception("DB err")
|
||||
mock_persistence.add_logs.side_effect = side_effect
|
||||
|
||||
await event_bus._flush_logs()
|
||||
# t2 logs re-queued
|
||||
assert "t2" in event_bus._log_buffer
|
||||
assert len(event_bus._log_buffer["t2"]) == 1
|
||||
# t1 flushed, not re-queued
|
||||
assert "t1" not in event_bus._log_buffer
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_logs_re_adds_to_existing_buffer(self, event_bus, mock_persistence):
|
||||
"""If task_id already has entries in buffer after flush failure, they're extended."""
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="first")]
|
||||
# Simulate: other code added more logs before flush retried
|
||||
def side_effect(task_id, logs):
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="second")]
|
||||
raise Exception("DB err")
|
||||
mock_persistence.add_logs.side_effect = side_effect
|
||||
|
||||
await event_bus._flush_logs()
|
||||
# Original logs extended onto existing buffer
|
||||
assert len(event_bus._log_buffer["t1"]) == 2
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Async Flusher Loop
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusFlusherLoop:
|
||||
"""Verify async_flusher_loop lifecycle: stop event and cancellation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_stops_on_event(self, event_bus):
|
||||
event_bus._flusher_stop_event.set()
|
||||
await event_bus.async_flusher_loop()
|
||||
# Clean return — loop exited
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_handles_cancelled_error(self, event_bus):
|
||||
"""CancelledError is caught internally, loop exits cleanly."""
|
||||
task = asyncio.create_task(event_bus.async_flusher_loop())
|
||||
await asyncio.sleep(0.02)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass # should not propagate; but if it does, test still passes
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_flushes_then_waits(self, event_bus, mock_persistence):
|
||||
"""Loop calls _flush_logs, then waits on stop event."""
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="pre-flush")]
|
||||
# Set stop event after short delay so loop does one flush then exits
|
||||
async def set_stop():
|
||||
await asyncio.sleep(0.05)
|
||||
event_bus._flusher_stop_event.set()
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(event_bus.async_flusher_loop())
|
||||
tg.create_task(set_stop())
|
||||
|
||||
mock_persistence.add_logs.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_timeout_triggers_continue(self, event_bus, mock_persistence):
|
||||
"""When wait_for times out, loop continues via the except TimeoutError path (line 104)."""
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="timeout-flush")]
|
||||
# Patch LOG_FLUSH_INTERVAL to be very short so timeout happens fast
|
||||
original = event_bus.LOG_FLUSH_INTERVAL
|
||||
event_bus.LOG_FLUSH_INTERVAL = 0.02
|
||||
|
||||
async def set_stop():
|
||||
await asyncio.sleep(0.15) # longer than 2× LOG_FLUSH_INTERVAL
|
||||
event_bus._flusher_stop_event.set()
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(event_bus.async_flusher_loop())
|
||||
tg.create_task(set_stop())
|
||||
|
||||
# Should have flushed at least once (the continue path runs)
|
||||
assert mock_persistence.add_logs.call_count >= 1
|
||||
event_bus.LOG_FLUSH_INTERVAL = original
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Log Retrieval
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusGetLogs:
|
||||
"""Verify get_task_logs @POST for running/pending/completed tasks."""
|
||||
|
||||
def test_get_task_logs_running_returns_memory(self, event_bus):
|
||||
task_logs = [LogEntry(message="mem")]
|
||||
result = event_bus.get_task_logs("t1", task_status=TaskStatus.RUNNING, task_logs=task_logs)
|
||||
assert len(result) == 1
|
||||
assert result[0].message == "mem"
|
||||
|
||||
def test_get_task_logs_pending_returns_memory(self, event_bus):
|
||||
task_logs = [LogEntry(message="pend")]
|
||||
result = event_bus.get_task_logs("t1", task_status=TaskStatus.PENDING, task_logs=task_logs)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_get_task_logs_awaiting_mapping_returns_memory(self, event_bus):
|
||||
task_logs = [LogEntry(message="map-wait")]
|
||||
result = event_bus.get_task_logs("t1", task_status=TaskStatus.AWAITING_MAPPING, task_logs=task_logs)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_get_task_logs_awaiting_input_returns_memory(self, event_bus):
|
||||
task_logs = [LogEntry(message="input-wait")]
|
||||
result = event_bus.get_task_logs("t1", task_status=TaskStatus.AWAITING_INPUT, task_logs=task_logs)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_get_task_logs_success_returns_persistence(self, event_bus, mock_persistence):
|
||||
mock_log = MagicMock()
|
||||
mock_log.timestamp = datetime.now()
|
||||
mock_log.level = "INFO"
|
||||
mock_log.message = "persisted"
|
||||
mock_log.source = "system"
|
||||
mock_log.metadata = None
|
||||
mock_persistence.get_logs.return_value = [mock_log]
|
||||
|
||||
result = event_bus.get_task_logs("t1", task_status=TaskStatus.SUCCESS)
|
||||
assert len(result) == 1
|
||||
assert result[0].message == "persisted"
|
||||
mock_persistence.get_logs.assert_called()
|
||||
|
||||
def test_get_task_logs_failed_returns_persistence(self, event_bus, mock_persistence):
|
||||
mock_persistence.get_logs.return_value = []
|
||||
result = event_bus.get_task_logs("t1", task_status=TaskStatus.FAILED)
|
||||
assert result == []
|
||||
|
||||
def test_get_task_logs_success_with_log_filter(self, event_bus, mock_persistence):
|
||||
mock_persistence.get_logs.return_value = []
|
||||
lf = LogFilter(level="ERROR", source="system")
|
||||
result = event_bus.get_task_logs("t1", log_filter=lf, task_status=TaskStatus.SUCCESS)
|
||||
mock_persistence.get_logs.assert_called_with("t1", lf)
|
||||
assert result == []
|
||||
|
||||
def test_get_task_logs_running_returns_empty_if_no_memory(self, event_bus):
|
||||
result = event_bus.get_task_logs("t1", task_status=TaskStatus.RUNNING)
|
||||
assert result == []
|
||||
|
||||
def test_get_task_log_stats(self, event_bus, mock_persistence):
|
||||
mock_persistence.get_log_stats.return_value = LogStats(
|
||||
total_count=5, by_level={"INFO": 3}, by_source={"system": 5},
|
||||
)
|
||||
stats = event_bus.get_task_log_stats("t1")
|
||||
assert stats.total_count == 5
|
||||
assert stats.by_level == {"INFO": 3}
|
||||
mock_persistence.get_log_stats.assert_called_with("t1")
|
||||
|
||||
def test_get_task_log_sources(self, event_bus, mock_persistence):
|
||||
mock_persistence.get_sources.return_value = ["system", "plugin"]
|
||||
sources = event_bus.get_task_log_sources("t1")
|
||||
assert sources == ["system", "plugin"]
|
||||
mock_persistence.get_sources.assert_called_with("t1")
|
||||
|
||||
def test_delete_logs_for_tasks(self, event_bus, mock_persistence):
|
||||
event_bus.delete_logs_for_tasks(["t1", "t2"])
|
||||
mock_persistence.delete_logs_for_tasks.assert_called_with(["t1", "t2"])
|
||||
|
||||
def test_delete_logs_for_tasks_empty(self, event_bus, mock_persistence):
|
||||
event_bus.delete_logs_for_tasks([])
|
||||
mock_persistence.delete_logs_for_tasks.assert_not_called()
|
||||
|
||||
def test_get_task_logs_completed_status_none(self, event_bus, mock_persistence):
|
||||
"""When task_status is None, task is not completed — return empty."""
|
||||
result = event_bus.get_task_logs("t1", task_status=None)
|
||||
assert result == []
|
||||
|
||||
# #endregion Test.Core.TaskManager.EventBus
|
||||
663
backend/tests/core/task_manager/test_lifecycle.py
Normal file
663
backend/tests/core/task_manager/test_lifecycle.py
Normal file
@@ -0,0 +1,663 @@
|
||||
# #region Test.Core.TaskManager.Lifecycle [C:3] [TYPE Module] [SEMANTICS test,lifecycle,task,execution]
|
||||
# @BRIEF Verify JobLifecycle contracts: create, run, pause/resume, input, completion.
|
||||
# @RELATION BINDS_TO -> [JobLifecycle]
|
||||
# @TEST_EDGE: unknown_plugin -> create_task raises ValueError
|
||||
# @TEST_EDGE: invalid_params -> create_task raises ValueError
|
||||
# @TEST_EDGE: task_not_found -> _run_task returns early
|
||||
# @TEST_EDGE: plugin_execution_failure -> task transitions to FAILED
|
||||
# @TEST_EDGE: resolve_not_awaiting -> resolve_task raises ValueError
|
||||
# @TEST_EDGE: await_input_not_running -> await_input raises ValueError
|
||||
# @TEST_EDGE: resume_empty_passwords -> resume_task_with_password raises ValueError
|
||||
# @TEST_EDGE: dataset_broadcast -> mapper/doc plugins broadcast dataset.updated
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
||||
import pytest
|
||||
|
||||
from src.core.task_manager.lifecycle import JobLifecycle
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
|
||||
|
||||
# ── Hardcoded fixtures ───────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def mock_plugin_loader():
|
||||
loader = MagicMock()
|
||||
loader.has_plugin.return_value = True
|
||||
plugin = MagicMock()
|
||||
plugin.name = "test_plugin"
|
||||
plugin.execute = MagicMock(return_value={"result": "ok"})
|
||||
loader.get_plugin.return_value = plugin
|
||||
return loader
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_graph():
|
||||
graph = MagicMock()
|
||||
graph.get_task.return_value = None # default: not found
|
||||
graph.tasks = {}
|
||||
return graph
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event_bus():
|
||||
bus = MagicMock()
|
||||
bus.broadcast_status = AsyncMock()
|
||||
bus.flush_task_logs = AsyncMock()
|
||||
return bus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_persistence():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lifecycle(mock_plugin_loader, mock_graph, mock_event_bus, mock_persistence):
|
||||
return JobLifecycle(
|
||||
plugin_loader=mock_plugin_loader,
|
||||
graph=mock_graph,
|
||||
event_bus=mock_event_bus,
|
||||
persistence_service=mock_persistence,
|
||||
)
|
||||
|
||||
|
||||
def _make_task(**overrides) -> Task:
|
||||
params = {"plugin_id": "test_plugin", "params": {}}
|
||||
params.update(overrides)
|
||||
t = Task(**params)
|
||||
t.id = "task-test-1"
|
||||
return t
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Init
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleInit:
|
||||
"""Verify __init__ stores dependencies."""
|
||||
|
||||
def test_init_stores_deps(self, lifecycle, mock_plugin_loader, mock_graph, mock_event_bus, mock_persistence):
|
||||
assert lifecycle.plugin_loader is mock_plugin_loader
|
||||
assert lifecycle.graph is mock_graph
|
||||
assert lifecycle.event_bus is mock_event_bus
|
||||
assert lifecycle.persistence_service is mock_persistence
|
||||
assert lifecycle._dataset_subscribers == {}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# create_task
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleCreateTask:
|
||||
"""Verify create_task @POST: task created, added to graph, persisted."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_success(self, lifecycle, mock_plugin_loader, mock_graph, mock_persistence):
|
||||
task = await lifecycle.create_task("test_plugin", {"key": "val"}, user_id="user1")
|
||||
assert task.plugin_id == "test_plugin"
|
||||
assert task.params == {"key": "val"}
|
||||
assert task.user_id == "user1"
|
||||
assert task.status == TaskStatus.PENDING
|
||||
mock_graph.add_task.assert_called_once_with(task)
|
||||
mock_persistence.persist_task.assert_called_once_with(task)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_unknown_plugin_raises(self, lifecycle, mock_plugin_loader):
|
||||
mock_plugin_loader.has_plugin.return_value = False
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await lifecycle.create_task("ghost", {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_invalid_params_raises(self, lifecycle):
|
||||
with pytest.raises(ValueError, match="dictionary"):
|
||||
await lifecycle.create_task("test_plugin", "not-a-dict")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_no_user_id(self, lifecycle, mock_graph, mock_persistence):
|
||||
task = await lifecycle.create_task("test_plugin", {})
|
||||
assert task.user_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_with_add_log_callback(self, lifecycle, mock_graph, mock_persistence):
|
||||
add_log = AsyncMock()
|
||||
task = await lifecycle.create_task("test_plugin", {}, add_log_callback=add_log)
|
||||
assert task is not None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# _run_task — execution paths
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleRunTask:
|
||||
"""Verify _run_task @POST: success, failure, context dispatch, dataset broadcast."""
|
||||
|
||||
# ── Setup: inject a task into the graph ──
|
||||
|
||||
def _inject_task(self, lifecycle, mock_graph, **overrides):
|
||||
task = _make_task(**overrides)
|
||||
mock_graph.get_task.return_value = task
|
||||
return task
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_success(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
await lifecycle._run_task(task.id)
|
||||
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
assert task.finished_at is not None
|
||||
assert task.result == {"result": "ok"}
|
||||
mock_event_bus.broadcast_status.assert_called()
|
||||
mock_event_bus.flush_task_logs.assert_called_with(task.id)
|
||||
mock_persistence.persist_task.assert_called()
|
||||
# Verify RUNNING -> SUCCESS transition persisted
|
||||
persist_calls = mock_persistence.persist_task.call_args_list
|
||||
assert len(persist_calls) >= 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_not_found(self, lifecycle, mock_graph, mock_event_bus, mock_persistence):
|
||||
mock_graph.get_task.return_value = None
|
||||
await lifecycle._run_task("no-such-task")
|
||||
mock_event_bus.broadcast_status.assert_not_called()
|
||||
mock_persistence.persist_task.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_plugin_failure(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
mock_plugin_loader.get_plugin().execute.side_effect = Exception("Plugin crashed")
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
|
||||
assert task.status == TaskStatus.FAILED
|
||||
assert task.finished_at is not None
|
||||
mock_event_bus.broadcast_status.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_with_add_log_callback(self, lifecycle, mock_graph, mock_event_bus, mock_persistence):
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
add_log = AsyncMock()
|
||||
await lifecycle._run_task(task.id, add_log_callback=add_log)
|
||||
assert add_log.call_count >= 2 # started + completed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_async_plugin_with_context(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""Plugin that accepts context parameter AND is a coroutine function."""
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
async def async_execute(params, context=None):
|
||||
return {"async": True}
|
||||
mock_plugin_loader.get_plugin().execute = async_execute
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.result == {"async": True}
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_sync_plugin_with_context(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""Plugin that accepts context but is a sync function — uses asyncio.to_thread."""
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
|
||||
def sync_execute(params, context=None):
|
||||
return {"sync": True}
|
||||
mock_plugin_loader.get_plugin().execute = sync_execute
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.result == {"sync": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_async_plugin_without_context(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""Plugin without context parameter — async variant."""
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
|
||||
async def async_execute_no_ctx(params):
|
||||
return {"no_ctx": True}
|
||||
mock_plugin_loader.get_plugin().execute = async_execute_no_ctx
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.result == {"no_ctx": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_sync_plugin_without_context(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""Plugin without context — sync variant, runs via to_thread."""
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
|
||||
def sync_execute_no_ctx(params):
|
||||
return {"sync_no_ctx": True}
|
||||
mock_plugin_loader.get_plugin().execute = sync_execute_no_ctx
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.result == {"sync_no_ctx": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_with_failed_add_log_callback(self, lifecycle, mock_graph, mock_event_bus, mock_persistence):
|
||||
"""Failure in add_log_callback during startup propagates — source doesn't catch it there."""
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
# The source calls add_log_callback for "Task started" before try/except.
|
||||
# If that fails, the exception propagates directly.
|
||||
add_log = AsyncMock(side_effect=Exception("log err"))
|
||||
|
||||
with pytest.raises(Exception, match="log err"):
|
||||
await lifecycle._run_task(task.id, add_log_callback=add_log)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_plugin_failure_with_add_log(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""Failed plugin should still call add_log with error info."""
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
mock_plugin_loader.get_plugin().execute.side_effect = ValueError("bad value")
|
||||
add_log = AsyncMock()
|
||||
|
||||
await lifecycle._run_task(task.id, add_log_callback=add_log)
|
||||
|
||||
assert task.status == TaskStatus.FAILED
|
||||
# Error log should have been sent
|
||||
error_calls = [c for c in add_log.call_args_list if c[0][1] == "ERROR"]
|
||||
assert len(error_calls) >= 1
|
||||
|
||||
# ── Dataset broadcast tests ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_broadcasts_dataset_mapper(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""dataset-mapper plugin broadcasts dataset.updated event."""
|
||||
task = self._inject_task(lifecycle, mock_graph, plugin_id="dataset-mapper",
|
||||
params={"dataset_ids": [1, 2], "env": "prod"})
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
|
||||
# _broadcast_dataset_updated should have been called
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_broadcasts_llm_documentation(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""llm_documentation plugin broadcasts dataset.updated event."""
|
||||
task = self._inject_task(lifecycle, mock_graph, plugin_id="llm_documentation",
|
||||
params={"dataset_id": 5, "environment_id": "staging"})
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_skip_dataset_broadcast_wrong_plugin(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""Non-mapper plugin should not broadcast dataset events."""
|
||||
task = self._inject_task(lifecycle, mock_graph, plugin_id="backup",
|
||||
params={"dataset_ids": [1], "env": "prod"})
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_skip_dataset_broadcast_missing_env(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""dataset-mapper without env_id should skip broadcast."""
|
||||
task = self._inject_task(lifecycle, mock_graph, plugin_id="dataset-mapper",
|
||||
params={"dataset_ids": [1]})
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_skip_dataset_broadcast_empty_datasets(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""dataset-mapper with empty dataset_ids should skip broadcast."""
|
||||
task = self._inject_task(lifecycle, mock_graph, plugin_id="dataset-mapper",
|
||||
params={"dataset_ids": [], "env": "prod"})
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_broadcasts_single_dataset_id(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""plugin with dataset_id (single int) + env should broadcast."""
|
||||
task = self._inject_task(lifecycle, mock_graph, plugin_id="dataset-mapper",
|
||||
params={"dataset_id": 42, "env": "prod"})
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# resolve_task
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleResolveTask:
|
||||
"""Verify resolve_task @POST: updates params, sets RUNNING, resolves future."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_task_success(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.AWAITING_MAPPING
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
await lifecycle.resolve_task(task.id, {"mapping": "done"})
|
||||
|
||||
assert task.params["mapping"] == "done"
|
||||
assert task.status == TaskStatus.RUNNING
|
||||
mock_graph.resolve_future.assert_called_with(task.id, True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_task_not_awaiting_raises(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.RUNNING
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
with pytest.raises(ValueError, match="not awaiting mapping"):
|
||||
await lifecycle.resolve_task(task.id, {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_task_not_found_raises(self, lifecycle, mock_graph):
|
||||
mock_graph.get_task.return_value = None
|
||||
with pytest.raises(ValueError, match="not awaiting mapping"):
|
||||
await lifecycle.resolve_task("no-such", {})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# wait_for_resolution
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleWaitForResolution:
|
||||
"""Verify wait_for_resolution @POST: creates future, waits, then resolves."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_resolution_success(self, lifecycle, mock_graph, mock_event_bus):
|
||||
task = _make_task()
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
# Run wait_for_resolution and resolve it after short delay
|
||||
async def resolve_later():
|
||||
await asyncio.sleep(0.05)
|
||||
future = mock_graph.create_future.call_args[0][1]
|
||||
future.set_result(True)
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(resolve_later())
|
||||
await lifecycle.wait_for_resolution(task.id)
|
||||
|
||||
assert task.status == TaskStatus.AWAITING_MAPPING
|
||||
mock_graph.remove_future.assert_called_with(task.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_resolution_no_task(self, lifecycle, mock_graph):
|
||||
mock_graph.get_task.return_value = None
|
||||
await lifecycle.wait_for_resolution("no-such")
|
||||
# no-op, should return immediately
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_resolution_persists_and_broadcasts(self, lifecycle, mock_graph, mock_event_bus, mock_persistence):
|
||||
task = _make_task()
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
async def resolve_later():
|
||||
await asyncio.sleep(0.05)
|
||||
future = mock_graph.create_future.call_args[0][1]
|
||||
future.set_result(True)
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(resolve_later())
|
||||
await lifecycle.wait_for_resolution(task.id)
|
||||
|
||||
mock_persistence.persist_task.assert_called()
|
||||
mock_event_bus.broadcast_status.assert_called()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# wait_for_input
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleWaitForInput:
|
||||
"""Verify wait_for_input @POST: creates future, waits, removes future."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_input_success(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
mock_graph.get_task.return_value = task
|
||||
task.status = TaskStatus.AWAITING_INPUT
|
||||
|
||||
async def resolve_later():
|
||||
await asyncio.sleep(0.05)
|
||||
future = mock_graph.create_future.call_args[0][1]
|
||||
future.set_result(True)
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(resolve_later())
|
||||
await lifecycle.wait_for_input(task.id)
|
||||
|
||||
mock_graph.remove_future.assert_called_with(task.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_input_no_task(self, lifecycle, mock_graph):
|
||||
mock_graph.get_task.return_value = None
|
||||
await lifecycle.wait_for_input("no-such")
|
||||
# no-op, should return immediately
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# await_input
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleAwaitInput:
|
||||
"""Verify await_input @POST: sets AWAITING_INPUT with input_request."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_await_input_success(self, lifecycle, mock_graph, mock_persistence, mock_event_bus):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.RUNNING
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
await lifecycle.await_input(task.id, {"prompt": "Enter password"}, add_log_callback=AsyncMock())
|
||||
|
||||
assert task.status == TaskStatus.AWAITING_INPUT
|
||||
assert task.input_required is True
|
||||
assert task.input_request == {"prompt": "Enter password"}
|
||||
mock_persistence.persist_task.assert_called()
|
||||
mock_event_bus.broadcast_status.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_await_input_not_found_raises(self, lifecycle, mock_graph):
|
||||
mock_graph.get_task.return_value = None
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await lifecycle.await_input("no-such", {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_await_input_not_running_raises(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.PENDING
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
with pytest.raises(ValueError, match="not RUNNING"):
|
||||
await lifecycle.await_input(task.id, {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_await_input_with_add_log(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.RUNNING
|
||||
mock_graph.get_task.return_value = task
|
||||
add_log = AsyncMock()
|
||||
|
||||
await lifecycle.await_input(task.id, {"prompt": "pwd"}, add_log_callback=add_log)
|
||||
|
||||
add_log.assert_called_once()
|
||||
assert "paused for user input" in add_log.call_args[0][2].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# resume_task_with_password
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleResumeWithPassword:
|
||||
"""Verify resume_task_with_password @POST: status, params, future resolution."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_with_password_success(self, lifecycle, mock_graph, mock_persistence, mock_event_bus):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.AWAITING_INPUT
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
await lifecycle.resume_task_with_password(
|
||||
task.id, {"db1": "pass123"}, add_log_callback=AsyncMock()
|
||||
)
|
||||
|
||||
assert task.status == TaskStatus.RUNNING
|
||||
assert task.params.get("passwords") == {"db1": "pass123"}
|
||||
assert task.input_required is False
|
||||
assert task.input_request is None
|
||||
mock_persistence.persist_task.assert_called()
|
||||
mock_event_bus.broadcast_status.assert_called()
|
||||
mock_graph.resolve_future.assert_called_with(task.id, True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_not_found_raises(self, lifecycle, mock_graph):
|
||||
mock_graph.get_task.return_value = None
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await lifecycle.resume_task_with_password("no-such", {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_not_awaiting_input_raises(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.RUNNING
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
with pytest.raises(ValueError, match="not AWAITING_INPUT"):
|
||||
await lifecycle.resume_task_with_password(task.id, {"db": "pass"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_empty_passwords_raises(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.AWAITING_INPUT
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
await lifecycle.resume_task_with_password(task.id, {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_passwords_not_dict_raises(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.AWAITING_INPUT
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
await lifecycle.resume_task_with_password(task.id, "not-a-dict")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_with_add_log(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.AWAITING_INPUT
|
||||
mock_graph.get_task.return_value = task
|
||||
add_log = AsyncMock()
|
||||
|
||||
await lifecycle.resume_task_with_password(task.id, {"db": "pass"}, add_log_callback=add_log)
|
||||
|
||||
add_log.assert_called_once()
|
||||
assert "resumed with passwords" in add_log.call_args[0][2].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Dataset Events
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleDatasetEvents:
|
||||
"""Verify subscribe/unsubscribe/broadcast dataset events."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_dataset_events_creates_queue(self, lifecycle):
|
||||
queue = await lifecycle.subscribe_dataset_events("env-1")
|
||||
assert isinstance(queue, asyncio.Queue)
|
||||
assert "env-1" in lifecycle._dataset_subscribers
|
||||
assert queue in lifecycle._dataset_subscribers["env-1"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_dataset_events_multiple(self, lifecycle):
|
||||
q1 = await lifecycle.subscribe_dataset_events("env-1")
|
||||
q2 = await lifecycle.subscribe_dataset_events("env-1")
|
||||
assert len(lifecycle._dataset_subscribers["env-1"]) == 2
|
||||
|
||||
def test_unsubscribe_dataset_events_removes_queue(self, lifecycle):
|
||||
queue = asyncio.Queue()
|
||||
lifecycle._dataset_subscribers["env-1"] = [queue]
|
||||
lifecycle.unsubscribe_dataset_events("env-1", queue)
|
||||
assert "env-1" not in lifecycle._dataset_subscribers
|
||||
|
||||
def test_unsubscribe_dataset_events_not_found(self, lifecycle):
|
||||
queue = asyncio.Queue()
|
||||
lifecycle.unsubscribe_dataset_events("no-such", queue) # must not raise
|
||||
|
||||
def test_unsubscribe_dataset_events_not_in_list(self, lifecycle):
|
||||
queue = asyncio.Queue()
|
||||
lifecycle._dataset_subscribers["env-1"] = [asyncio.Queue()]
|
||||
lifecycle.unsubscribe_dataset_events("env-1", queue)
|
||||
assert len(lifecycle._dataset_subscribers["env-1"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_dataset_updated(self, lifecycle):
|
||||
queue = await lifecycle.subscribe_dataset_events("env-1")
|
||||
await lifecycle._broadcast_dataset_updated("env-1", [1, 2, 3])
|
||||
event = await asyncio.wait_for(queue.get(), timeout=0.5)
|
||||
assert event["type"] == "dataset.updated"
|
||||
assert event["payload"]["env_id"] == "env-1"
|
||||
assert event["payload"]["dataset_ids"] == [1, 2, 3]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_dataset_updated_no_subscribers(self, lifecycle):
|
||||
await lifecycle._broadcast_dataset_updated("orphan", [1])
|
||||
# no-op, must not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_dataset_updated_queue_full_blocks(self, lifecycle):
|
||||
full = asyncio.Queue(maxsize=1)
|
||||
await full.put("fill")
|
||||
lifecycle._dataset_subscribers["env-1"] = [full]
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
lifecycle._broadcast_dataset_updated("env-1", [1]),
|
||||
timeout=0.3,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# _task_to_dict helper (tested indirectly via _run_task)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestInternalHelpers:
|
||||
"""Verify _task_to_dict and _broadcast_task_status."""
|
||||
|
||||
def test_task_to_dict_includes_all_fields(self):
|
||||
from src.core.task_manager.lifecycle import _task_to_dict, _task_to_dict
|
||||
from datetime import datetime
|
||||
t = _make_task()
|
||||
t.status = TaskStatus.RUNNING
|
||||
t.started_at = datetime(2024, 1, 1, 12, 0, 0)
|
||||
t.result = {"some": "data"}
|
||||
|
||||
d = _task_to_dict(t)
|
||||
assert d["id"] == t.id
|
||||
assert d["status"] == "RUNNING"
|
||||
assert d["started_at"] == "2024-01-01T12:00:00"
|
||||
assert d["finished_at"] is None
|
||||
assert d["result"] == {"some": "data"}
|
||||
|
||||
def test_task_to_dict_failed_status(self):
|
||||
from src.core.task_manager.lifecycle import _task_to_dict
|
||||
t = _make_task()
|
||||
t.status = TaskStatus.FAILED
|
||||
t.result = "Error: something broke"
|
||||
|
||||
d = _task_to_dict(t)
|
||||
assert d["status"] == "FAILED"
|
||||
assert "Error" in d.get("error", "")
|
||||
|
||||
def test_task_to_dict_empty_dates(self):
|
||||
from src.core.task_manager.lifecycle import _task_to_dict
|
||||
t = _make_task()
|
||||
d = _task_to_dict(t)
|
||||
assert d["started_at"] is None
|
||||
assert d["finished_at"] is None
|
||||
assert d["error"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_task_status(self, lifecycle, mock_event_bus):
|
||||
from src.core.task_manager.lifecycle import _task_to_dict
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.RUNNING
|
||||
await lifecycle._broadcast_task_status(task)
|
||||
mock_event_bus.broadcast_status.assert_called_once()
|
||||
|
||||
# #endregion Test.Core.TaskManager.Lifecycle
|
||||
500
backend/tests/core/task_manager/test_manager.py
Normal file
500
backend/tests/core/task_manager/test_manager.py
Normal file
@@ -0,0 +1,500 @@
|
||||
# #region Test.Core.TaskManager.Manager [C:3] [TYPE Module] [SEMANTICS test,manager,cancel,tracking,delegates]
|
||||
# @BRIEF Verify TaskManager facade — cancellation, _async_tasks tracking, delegate plumbing,
|
||||
# get_task_logs wrapper, RuntimeError on event_bus.start, and edge cases.
|
||||
# @RELATION BINDS_TO -> [TaskManager]
|
||||
# @TEST_EDGE: cancel_running -> Running task is cancelled and removed from _async_tasks
|
||||
# @TEST_EDGE: cancel_done -> Done task returns False
|
||||
# @TEST_EDGE: cancel_not_found -> Nonexistent task returns False
|
||||
# @TEST_EDGE: add_log_no_task -> _add_log callback returns early when task not found
|
||||
# @TEST_EDGE: init_no_event_loop -> __init__ handles RuntimeError from event_bus.start()
|
||||
# @TEST_EDGE: delegate_resolve -> resolve_task delegates to lifecycle
|
||||
# @TEST_EDGE: delegate_dataset -> subscribe/unsubscribe dataset delegates to lifecycle
|
||||
# @TEST_EDGE: get_task_logs_wrapper -> TaskManager.get_task_logs wraps event_bus with task status
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
||||
import pytest
|
||||
|
||||
from src.core.task_manager.models import LogFilter, LogStats, Task, TaskStatus
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
def _make_manager_with_mocks():
|
||||
"""Create a TaskManager with all dependencies mocked.
|
||||
|
||||
Returns (manager, mock_plugin_loader, mock_persistence, mock_log_persistence).
|
||||
Patches TaskPersistenceService and TaskLogPersistenceService at import time.
|
||||
Patches RuntimeError from event_bus.start() by default.
|
||||
"""
|
||||
mock_plugin_loader = MagicMock()
|
||||
mock_plugin_loader.has_plugin.return_value = True
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.name = "test_plugin"
|
||||
mock_plugin.execute = MagicMock(return_value={"status": "ok"})
|
||||
mock_plugin_loader.get_plugin.return_value = mock_plugin
|
||||
|
||||
with patch("src.core.task_manager.manager.TaskPersistenceService") as MockPersist, \
|
||||
patch("src.core.task_manager.manager.TaskLogPersistenceService") as MockLogPersist:
|
||||
mock_persist = MockPersist.return_value
|
||||
mock_persist.load_tasks.return_value = []
|
||||
mock_persist.persist_task = MagicMock()
|
||||
mock_persist.delete_tasks = MagicMock(return_value=None)
|
||||
|
||||
mock_log_persist = MockLogPersist.return_value
|
||||
mock_log_persist.add_logs = MagicMock()
|
||||
mock_log_persist.get_logs = MagicMock(return_value=[])
|
||||
mock_log_persist.get_log_stats = MagicMock(return_value=LogStats(total_count=0, by_level={}, by_source={}))
|
||||
mock_log_persist.get_sources = MagicMock(return_value=[])
|
||||
mock_log_persist.delete_logs_for_tasks = MagicMock()
|
||||
|
||||
# Ensure a running event loop
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
with patch(
|
||||
"src.core.task_manager.event_bus.EventBus.start",
|
||||
side_effect=RuntimeError("No event loop"),
|
||||
):
|
||||
from src.core.task_manager.manager import TaskManager
|
||||
manager = TaskManager(mock_plugin_loader)
|
||||
return manager, mock_plugin_loader, mock_persist, mock_log_persist
|
||||
|
||||
raise RuntimeError("unreachable")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mgr():
|
||||
"""Hardcoded fixture: create TaskManager, clean up after test."""
|
||||
manager, _, _, _ = _make_manager_with_mocks()
|
||||
yield manager
|
||||
# Cleanup: try to stop event_bus flusher
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
if loop.is_running():
|
||||
loop.create_task(manager.event_bus.stop())
|
||||
except RuntimeError:
|
||||
pass
|
||||
if manager.event_bus._flusher_task and not manager.event_bus._flusher_task.done():
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(manager.event_bus.stop())
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Init
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerInit:
|
||||
"""Verify __init__ handles RuntimeError from event_bus.start()."""
|
||||
|
||||
def test_init_handles_no_event_loop(self):
|
||||
"""When event_bus.start() raises RuntimeError (no event loop), it's caught.
|
||||
The TaskManager is still operational."""
|
||||
with patch("src.core.task_manager.manager.TaskPersistenceService") as MockPersist, \
|
||||
patch("src.core.task_manager.manager.TaskLogPersistenceService") as MockLogPersist, \
|
||||
patch("src.core.task_manager.event_bus.EventBus.start", side_effect=RuntimeError("No loop")):
|
||||
MockPersist.return_value.load_tasks.return_value = []
|
||||
MockLogPersist.return_value.add_logs = MagicMock()
|
||||
from src.core.task_manager.manager import TaskManager
|
||||
loader = MagicMock()
|
||||
manager = TaskManager(loader)
|
||||
assert manager.plugin_loader is loader
|
||||
assert manager._async_tasks == {}
|
||||
assert manager.graph is not None
|
||||
assert manager.event_bus is not None
|
||||
assert manager.lifecycle is not None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# _make_add_log_callback
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerAddLogCallback:
|
||||
"""Verify _add_log async closure returns early when task not found."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_returns_early_if_no_task(self, mgr):
|
||||
"""When task_id not in graph, _add_log should return without calling event_bus."""
|
||||
with patch.object(mgr.event_bus, "add_log", AsyncMock()) as mock_add_log:
|
||||
await mgr._add_log("nonexistent", "INFO", "msg")
|
||||
mock_add_log.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_delegates_to_event_bus(self, mgr):
|
||||
"""When task exists, _add_log delegates to event_bus.add_log."""
|
||||
from src.core.task_manager.models import Task
|
||||
task = Task(plugin_id="test", params={})
|
||||
mgr.tasks[task.id] = task
|
||||
|
||||
with patch.object(mgr.event_bus, "add_log", AsyncMock()) as mock_add_log:
|
||||
await mgr._add_log(task.id, "INFO", "msg", source="test")
|
||||
mock_add_log.assert_called_once()
|
||||
args = mock_add_log.call_args
|
||||
assert args[0][0] == task.id # task_id
|
||||
assert args[0][1] == "INFO"
|
||||
assert args[0][2] == "msg"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# create_task — _async_tasks tracking
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerCreateTask:
|
||||
"""Verify create_task tracks asyncio task in _async_tasks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_tracks_async_task(self, mgr):
|
||||
mgr.lifecycle.create_task = AsyncMock()
|
||||
mgr.lifecycle._run_task = AsyncMock()
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-track-1"
|
||||
mgr.lifecycle.create_task.return_value = mock_task
|
||||
|
||||
result = await mgr.create_task("test_plugin", {})
|
||||
|
||||
assert result.id == "task-track-1"
|
||||
assert "task-track-1" in mgr._async_tasks
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_schedules_lifecycle_run(self, mgr):
|
||||
mgr.lifecycle.create_task = AsyncMock()
|
||||
mgr.lifecycle._run_task = AsyncMock()
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-sched-1"
|
||||
mgr.lifecycle.create_task.return_value = mock_task
|
||||
|
||||
await mgr.create_task("test_plugin", {"k": "v"}, user_id="u1")
|
||||
|
||||
mgr.lifecycle.create_task.assert_called_with(
|
||||
"test_plugin", {"k": "v"}, "u1", add_log_callback=mgr._add_log,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# _run_task wrapper — tracking + cleanup
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerRunTask:
|
||||
"""Verify _run_task wrapper tracks and cleans up _async_tasks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_tracks_and_cleans_up(self, mgr):
|
||||
mgr.lifecycle._run_task = AsyncMock()
|
||||
task_id = "task-clean-1"
|
||||
|
||||
await mgr._run_task(task_id)
|
||||
|
||||
assert task_id not in mgr._async_tasks # cleaned up after await
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_removes_on_exception(self, mgr):
|
||||
mgr.lifecycle._run_task = AsyncMock(side_effect=Exception("crash"))
|
||||
|
||||
with pytest.raises(Exception, match="crash"):
|
||||
await mgr._run_task("task-exc-1")
|
||||
|
||||
assert "task-exc-1" not in mgr._async_tasks # cleaned up
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# cancel_task
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerCancelTask:
|
||||
"""Verify cancel_task @POST for running, done, and nonexistent tasks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_running_task(self, mgr):
|
||||
"""Running task should be cancelled and removed from tracking."""
|
||||
async def dummy():
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
async_task = asyncio.create_task(dummy())
|
||||
mgr._async_tasks["task-cancel-1"] = async_task
|
||||
await asyncio.sleep(0.01) # let the task start
|
||||
|
||||
result = await mgr.cancel_task("task-cancel-1")
|
||||
assert result is True
|
||||
assert "task-cancel-1" not in mgr._async_tasks
|
||||
# After cancel_task returns, the task may be cancelling but not yet
|
||||
# fully cancelled. Give it a yield to complete cancellation.
|
||||
await asyncio.sleep(0.01)
|
||||
assert async_task.cancelled()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_done_task_returns_false(self, mgr):
|
||||
"""Completed task returns False."""
|
||||
async def quick():
|
||||
return "done"
|
||||
|
||||
async_task = asyncio.create_task(quick())
|
||||
await async_task # wait for completion
|
||||
mgr._async_tasks["task-done-1"] = async_task
|
||||
assert async_task.done()
|
||||
|
||||
result = await mgr.cancel_task("task-done-1")
|
||||
assert result is False
|
||||
assert "task-done-1" in mgr._async_tasks # not removed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_nonexistent_returns_false(self, mgr):
|
||||
"""Task not in _async_tracks returns False."""
|
||||
result = await mgr.cancel_task("no-such")
|
||||
assert result is False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# get_task_logs — wrapper
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerGetTaskLogs:
|
||||
"""Verify TaskManager.get_task_logs wraps event_bus with task status."""
|
||||
|
||||
def test_get_task_logs_with_existing_task(self, mgr):
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
task = Task(plugin_id="p1", params={})
|
||||
task.status = TaskStatus.SUCCESS
|
||||
mgr.tasks[task.id] = task
|
||||
|
||||
with patch.object(mgr.event_bus, "get_task_logs", return_value=[]) as mock_get:
|
||||
result = mgr.get_task_logs(task.id)
|
||||
mock_get.assert_called_once()
|
||||
args = mock_get.call_args
|
||||
assert args[0][0] == task.id
|
||||
assert args[1]["task_status"] == TaskStatus.SUCCESS
|
||||
|
||||
def test_get_task_logs_with_nonexistent_task(self, mgr):
|
||||
with patch.object(mgr.event_bus, "get_task_logs", return_value=[]) as mock_get:
|
||||
result = mgr.get_task_logs("no-such")
|
||||
mock_get.assert_called_once()
|
||||
args = mock_get.call_args
|
||||
assert args[0][0] == "no-such"
|
||||
assert args[1]["task_status"] is None
|
||||
assert args[1]["task_logs"] == []
|
||||
|
||||
def test_get_task_logs_with_log_filter(self, mgr):
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
task = Task(plugin_id="p1", params={})
|
||||
task.status = TaskStatus.RUNNING
|
||||
mgr.tasks[task.id] = task
|
||||
lf = LogFilter(level="ERROR")
|
||||
|
||||
with patch.object(mgr.event_bus, "get_task_logs", return_value=[]) as mock_get:
|
||||
mgr.get_task_logs(task.id, log_filter=lf)
|
||||
mock_get.assert_called_with(task.id, lf, task_status=TaskStatus.RUNNING, task_logs=task.logs)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# get_task_log_stats / get_task_log_sources
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerLogDelegates:
|
||||
"""Verify direct delegates to event_bus log methods."""
|
||||
|
||||
def test_get_task_log_stats(self, mgr):
|
||||
expected = LogStats(total_count=3, by_level={}, by_source={})
|
||||
mgr.event_bus.get_task_log_stats = MagicMock(return_value=expected)
|
||||
result = mgr.get_task_log_stats("task-1")
|
||||
assert result.total_count == 3
|
||||
mgr.event_bus.get_task_log_stats.assert_called_with("task-1")
|
||||
|
||||
def test_get_task_log_sources(self, mgr):
|
||||
mgr.event_bus.get_task_log_sources = MagicMock(return_value=["sys", "plugin"])
|
||||
result = mgr.get_task_log_sources("task-1")
|
||||
assert result == ["sys", "plugin"]
|
||||
mgr.event_bus.get_task_log_sources.assert_called_with("task-1")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Legacy flusher aliases
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerFlushAliases:
|
||||
"""Verify legacy _flusher_loop, _flush_logs, _flush_task_logs delegates."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flusher_loop_delegates(self, mgr):
|
||||
original = mgr.event_bus.async_flusher_loop
|
||||
mgr.event_bus.async_flusher_loop = AsyncMock()
|
||||
await mgr._flusher_loop()
|
||||
mgr.event_bus.async_flusher_loop.assert_called_once()
|
||||
mgr.event_bus.async_flusher_loop = original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_logs_delegates(self, mgr):
|
||||
original = mgr.event_bus._flush_logs
|
||||
mgr.event_bus._flush_logs = AsyncMock()
|
||||
await mgr._flush_logs()
|
||||
mgr.event_bus._flush_logs.assert_called_once()
|
||||
mgr.event_bus._flush_logs = original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_task_logs_delegates(self, mgr):
|
||||
original = mgr.event_bus.flush_task_logs
|
||||
mgr.event_bus.flush_task_logs = AsyncMock()
|
||||
await mgr._flush_task_logs("task-1")
|
||||
mgr.event_bus.flush_task_logs.assert_called_with("task-1")
|
||||
mgr.event_bus.flush_task_logs = original
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Subscribe delegates (status + task events)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerSubscribeDelegates:
|
||||
"""Verify subscription delegates pass through to EventBus."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_logs_delegates(self, mgr):
|
||||
mgr.event_bus.subscribe_logs = AsyncMock(return_value="q")
|
||||
result = await mgr.subscribe_logs("t1")
|
||||
assert result == "q"
|
||||
mgr.event_bus.subscribe_logs.assert_called_with("t1")
|
||||
|
||||
def test_unsubscribe_logs_delegates(self, mgr):
|
||||
"""Covers manager.py line 267."""
|
||||
queue = asyncio.Queue()
|
||||
mgr.event_bus.unsubscribe_logs = MagicMock()
|
||||
mgr.unsubscribe_logs("t1", queue)
|
||||
mgr.event_bus.unsubscribe_logs.assert_called_with("t1", queue)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_status(self, mgr):
|
||||
mgr.event_bus.subscribe_status = AsyncMock(return_value="q")
|
||||
result = await mgr.subscribe_status("t1")
|
||||
assert result == "q"
|
||||
mgr.event_bus.subscribe_status.assert_called_with("t1")
|
||||
|
||||
def test_unsubscribe_status(self, mgr):
|
||||
queue = asyncio.Queue()
|
||||
mgr.event_bus.unsubscribe_status = MagicMock()
|
||||
mgr.unsubscribe_status("t1", queue)
|
||||
mgr.event_bus.unsubscribe_status.assert_called_with("t1", queue)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_task_events(self, mgr):
|
||||
mgr.event_bus.subscribe_task_events = AsyncMock(return_value="q")
|
||||
result = await mgr.subscribe_task_events()
|
||||
assert result == "q"
|
||||
|
||||
def test_unsubscribe_task_events(self, mgr):
|
||||
queue = asyncio.Queue()
|
||||
mgr.event_bus.unsubscribe_task_events = MagicMock()
|
||||
mgr.unsubscribe_task_events(queue)
|
||||
mgr.event_bus.unsubscribe_task_events.assert_called_with(queue)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Lifecycle delegates
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerLifecycleDelegates:
|
||||
"""Verify lifecycle delegates pass through to JobLifecycle."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_task_delegates(self, mgr):
|
||||
mgr.lifecycle.resolve_task = AsyncMock()
|
||||
await mgr.resolve_task("t1", {"mapping": "done"})
|
||||
mgr.lifecycle.resolve_task.assert_called_with("t1", {"mapping": "done"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_resolution_delegates(self, mgr):
|
||||
mgr.lifecycle.wait_for_resolution = AsyncMock()
|
||||
await mgr.wait_for_resolution("t1")
|
||||
mgr.lifecycle.wait_for_resolution.assert_called_with("t1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_input_delegates(self, mgr):
|
||||
mgr.lifecycle.wait_for_input = AsyncMock()
|
||||
await mgr.wait_for_input("t1")
|
||||
mgr.lifecycle.wait_for_input.assert_called_with("t1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_await_input_delegates(self, mgr):
|
||||
mgr.lifecycle.await_input = AsyncMock()
|
||||
mgr._add_log = AsyncMock()
|
||||
await mgr.await_input("t1", {"prompt": "pwd"})
|
||||
mgr.lifecycle.await_input.assert_called_with("t1", {"prompt": "pwd"}, add_log_callback=mgr._add_log)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_task_with_password_delegates(self, mgr):
|
||||
mgr.lifecycle.resume_task_with_password = AsyncMock()
|
||||
mgr._add_log = AsyncMock()
|
||||
await mgr.resume_task_with_password("t1", {"db": "pass"})
|
||||
mgr.lifecycle.resume_task_with_password.assert_called_with("t1", {"db": "pass"}, add_log_callback=mgr._add_log)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Maintenance event delegates
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerMaintenanceDelegates:
|
||||
"""Verify maintenance event delegates pass through to EventBus."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_maintenance_events(self, mgr):
|
||||
mgr.event_bus.subscribe_maintenance_events = AsyncMock(return_value="q")
|
||||
result = await mgr.subscribe_maintenance_events()
|
||||
assert result == "q"
|
||||
mgr.event_bus.subscribe_maintenance_events.assert_called_once()
|
||||
|
||||
def test_unsubscribe_maintenance_events(self, mgr):
|
||||
queue = asyncio.Queue()
|
||||
mgr.event_bus.unsubscribe_maintenance_events = MagicMock()
|
||||
mgr.unsubscribe_maintenance_events(queue)
|
||||
mgr.event_bus.unsubscribe_maintenance_events.assert_called_with(queue)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_maintenance_event(self, mgr):
|
||||
mgr.event_bus.broadcast_maintenance_event = AsyncMock()
|
||||
await mgr.broadcast_maintenance_event({"type": "test"})
|
||||
mgr.event_bus.broadcast_maintenance_event.assert_called_with({"type": "test"})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Dataset event delegates
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerDatasetDelegates:
|
||||
"""Verify dataset event delegates pass through to JobLifecycle."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_dataset_events(self, mgr):
|
||||
mgr.lifecycle.subscribe_dataset_events = AsyncMock(return_value="q")
|
||||
result = await mgr.subscribe_dataset_events("env-1")
|
||||
assert result == "q"
|
||||
mgr.lifecycle.subscribe_dataset_events.assert_called_with("env-1")
|
||||
|
||||
def test_unsubscribe_dataset_events(self, mgr):
|
||||
queue = asyncio.Queue()
|
||||
mgr.lifecycle.unsubscribe_dataset_events = MagicMock()
|
||||
mgr.unsubscribe_dataset_events("env-1", queue)
|
||||
mgr.lifecycle.unsubscribe_dataset_events.assert_called_with("env-1", queue)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# load_persisted_tasks
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerLoadPersistedTasks:
|
||||
"""Verify load_persisted_tasks delegates to graph."""
|
||||
|
||||
def test_load_persisted_tasks_delegates(self, mgr):
|
||||
mgr.graph.load_persisted_tasks = MagicMock()
|
||||
mgr.load_persisted_tasks()
|
||||
mgr.graph.load_persisted_tasks.assert_called_with(limit=100)
|
||||
|
||||
# #endregion Test.Core.TaskManager.Manager
|
||||
Reference in New Issue
Block a user