ytptube/app/tests/test_events.py
2026-05-04 22:23:19 +03:00

984 lines
36 KiB
Python

import asyncio
import datetime
from unittest.mock import MagicMock, patch
import pytest
from app.library.Events import Event, EventBus, EventListener, Events
class TestEvents:
"""Test the Events constants class."""
def test_events_get_all(self):
"""Test Events.get_all() method returns all constants."""
all_events = Events.get_all()
# Should be a list
assert isinstance(all_events, list)
# Should contain expected events
expected_events = [
"startup",
"loaded",
"started",
"shutdown",
"connected",
"config_update",
"log_info",
"log_warning",
"log_error",
"log_success",
"item_added",
"item_updated",
"item_completed",
"item_cancelled",
"item_deleted",
"item_bulk_deleted",
"item_paused",
"item_resumed",
"item_moved",
"item_status",
"item_error",
"test",
"add_url",
"paused",
"resumed",
]
for expected in expected_events:
assert expected in all_events
# Should not contain private or callable attributes
for event in all_events:
assert not event.startswith("_")
assert isinstance(event, str)
def test_events_frontend(self):
"""Test Events.frontend() method returns frontend events."""
frontend_events = Events.frontend()
assert isinstance(frontend_events, list)
# Check some expected frontend events
expected_frontend = [
Events.CONNECTED,
Events.CONFIG_UPDATE,
Events.LOG_INFO,
Events.LOG_WARNING,
Events.LOG_ERROR,
Events.LOG_SUCCESS,
Events.ITEM_ADDED,
Events.ITEM_UPDATED,
Events.ITEM_BULK_DELETED,
]
for expected in expected_frontend:
assert expected in frontend_events
def test_events_only_debug(self):
"""Test Events.only_debug() method returns debug-only events."""
debug_events = Events.only_debug()
assert isinstance(debug_events, list)
assert Events.ITEM_UPDATED in debug_events
class TestEvent:
"""Test the Event dataclass."""
def test_event_creation_minimal(self):
"""Test creating an Event with minimal required parameters."""
event = Event(event="test_event", data={"key": "value"})
# Check required fields
assert event.event == "test_event"
assert event.data == {"key": "value"}
# Check auto-generated fields
assert event.id is not None
assert isinstance(event.id, str)
assert event.created_at is not None
# Check optional fields default to None
assert event.title is None
assert event.message is None
def test_event_creation_with_all_fields(self):
"""Test creating an Event with all fields specified."""
test_data = {"test": "data"}
event = Event(event="custom_event", title="Test Title", message="Test Message", data=test_data)
assert event.event == "custom_event"
assert event.title == "Test Title"
assert event.message == "Test Message"
assert event.data == test_data
assert event.id is not None
assert event.created_at is not None
def test_event_serialize(self):
"""Test Event serialization."""
test_data = {"nested": {"key": "value"}}
event = Event(event="serialize_test", title="Serialize Title", message="Serialize Message", data=test_data)
serialized = event.serialize()
assert isinstance(serialized, dict)
assert serialized["event"] == "serialize_test"
assert serialized["title"] == "Serialize Title"
assert serialized["message"] == "Serialize Message"
assert serialized["data"] == test_data
assert "id" in serialized
assert "created_at" in serialized
def test_event_datatype(self):
"""Test Event datatype() method."""
# Test with dictionary data
event_dict = Event(event="test", data={"key": "value"})
assert event_dict.datatype() == "dict"
# Test with list data
event_list = Event(event="test", data=["item1", "item2"])
assert event_list.datatype() == "list"
# Test with string data
event_str = Event(event="test", data="string_data")
assert event_str.datatype() == "str"
# Test with None data
event_none = Event(event="test", data=None)
assert event_none.datatype() == "NoneType"
def test_event_str_representation(self):
"""Test Event string representations."""
event = Event(event="repr_test", title="Repr Title", message="Repr Message", data={"test": True})
# Test __str__ method
str_repr = str(event)
assert "repr_test" in str_repr
assert "Repr Title" in str_repr
assert "Repr Message" in str_repr
assert event.id in str_repr
# Test __repr__ method
repr_str = repr(event)
assert "Event(" in repr_str
assert "repr_test" in repr_str
assert event.id in repr_str
def test_event_created_at_format(self):
"""Test that created_at is in ISO format."""
event = Event(event="time_test", data={})
# Should be able to parse as ISO datetime
parsed_time = datetime.datetime.fromisoformat(event.created_at)
assert isinstance(parsed_time, datetime.datetime)
# Should be recent (within last few seconds)
now = datetime.datetime.now(tz=datetime.UTC)
time_diff = abs((now - parsed_time).total_seconds())
assert time_diff < 5 # Within 5 seconds
def test_event_put_method(self):
"""Test Event.put() method for adding extra data."""
event = Event(event="put_test", data={"original": "data"})
# Initially extras should be empty
assert event.extras == {}
# Test putting single value
event.put("key1", "value1")
assert event.extras["key1"] == "value1"
assert len(event.extras) == 1
# Test putting multiple values
event.put("key2", 42)
event.put("key3", {"nested": "object"})
assert event.extras["key2"] == 42
assert event.extras["key3"] == {"nested": "object"}
assert len(event.extras) == 3
# Test overwriting existing key
event.put("key1", "new_value1")
assert event.extras["key1"] == "new_value1"
assert len(event.extras) == 3
# Test putting None value
event.put("key4", None)
assert event.extras["key4"] is None
assert len(event.extras) == 4
def test_event_put_with_complex_data(self):
"""Test Event.put() with complex data types."""
event = Event(event="complex_put_test", data={})
# Test with list
test_list = [1, 2, 3, "string", {"nested": True}]
event.put("list_data", test_list)
assert event.extras["list_data"] == test_list
# Test with dictionary
test_dict = {"a": 1, "b": {"c": 2}, "d": [4, 5, 6]}
event.put("dict_data", test_dict)
assert event.extras["dict_data"] == test_dict
# Test with callable (function reference)
def test_func():
return "test"
event.put("func_data", test_func)
assert event.extras["func_data"] == test_func
assert event.extras["func_data"]() == "test"
def test_event_put_extras_persistence(self):
"""Test that extras persist and don't interfere with original data."""
original_data = {"original": "value"}
event = Event(event="persistence_test", data=original_data)
# Add extras
event.put("extra1", "value1")
event.put("extra2", "value2")
# Original data should be unchanged
assert event.data == original_data
assert event.data is original_data # Same object reference
# Extras should be separate
assert event.extras == {"extra1": "value1", "extra2": "value2"}
assert "extra1" not in event.data
assert "extra2" not in event.data
@pytest.mark.asyncio
async def test_event_mutation_between_listeners(self):
"""Test that multiple listeners can mutate the same event and see each other's changes."""
bus = EventBus()
mutations = []
async def listener1(event, name, **kwargs): # noqa: ARG001
# First listener adds data
event.put("listener1", "data1")
event.put("shared_counter", 1)
mutations.append("listener1_executed")
async def listener2(event, name, **kwargs): # noqa: ARG001
# Second listener can see and modify first listener's data
assert event.extras.get("listener1") == "data1"
assert event.extras.get("shared_counter") == 1
# Add its own data
event.put("listener2", "data2")
# Increment shared counter
event.put("shared_counter", event.extras.get("shared_counter", 0) + 1)
mutations.append("listener2_executed")
async def listener3(event, name, **kwargs): # noqa: ARG001
# Third listener can see all previous mutations
assert event.extras.get("listener1") == "data1"
assert event.extras.get("listener2") == "data2"
assert event.extras.get("shared_counter") == 2
# Add final data
event.put("listener3", "data3")
event.put("final_count", len(event.extras))
mutations.append("listener3_executed")
# Subscribe listeners in order
bus.subscribe(Events.TEST, listener1, "listener1")
bus.subscribe(Events.TEST, listener2, "listener2")
bus.subscribe(Events.TEST, listener3, "listener3")
# Emit event
bus.emit(Events.TEST, data={"original": "data"})
# Wait for execution (fire-and-forget)
await asyncio.sleep(0.01)
# Verify all listeners executed
assert mutations == ["listener1_executed", "listener2_executed", "listener3_executed"]
@pytest.mark.asyncio
async def test_event_mutation_with_async_listeners(self):
"""Test event mutation with async listeners."""
bus = EventBus()
mutations = []
async def async_listener1(event, name, **kwargs): # noqa: ARG001
event.put("async1", "value1")
mutations.append("async1")
async def async_listener2(event, name, **kwargs): # noqa: ARG001
# Can see first async listener's data
assert event.extras.get("async1") == "value1"
event.put("async2", "value2")
mutations.append("async2")
async def sync_listener(event, name, **kwargs): # noqa: ARG001
# Can see both async listeners' data
assert event.extras.get("async1") == "value1"
assert event.extras.get("async2") == "value2"
event.put("sync", "value3")
mutations.append("sync")
bus.subscribe(Events.STARTUP, async_listener1, "async1")
bus.subscribe(Events.STARTUP, async_listener2, "async2")
bus.subscribe(Events.STARTUP, sync_listener, "sync")
bus.emit(Events.STARTUP, data={"test": "data"})
# Wait for execution
await asyncio.sleep(0.01)
assert mutations == ["async1", "async2", "sync"]
@pytest.mark.asyncio
async def test_event_mutation_data_types(self):
"""Test event mutation with various data types."""
bus = EventBus()
final_extras = {}
async def collector(event, name, **kwargs): # noqa: ARG001
# Store reference to final extras
nonlocal final_extras
final_extras = event.extras
async def mutator1(event, name, **kwargs): # noqa: ARG001
event.put("string", "test")
event.put("number", 42)
event.put("list", [1, 2, 3])
async def mutator2(event, name, **kwargs): # noqa: ARG001
# Modify existing list
existing_list = event.extras.get("list", [])
existing_list.append(4)
event.put("list", existing_list)
event.put("dict", {"key": "value"})
is_true = True
event.put("bool", is_true)
async def mutator3(event, name, **kwargs): # noqa: ARG001
# Modify existing dict
existing_dict = event.extras.get("dict", {})
existing_dict["new_key"] = "new_value"
event.put("dict", existing_dict)
event.put("nested", {"list": [1, 2], "dict": {"inner": "value"}})
bus.subscribe(Events.SHUTDOWN, mutator1, "mutator1")
bus.subscribe(Events.SHUTDOWN, mutator2, "mutator2")
bus.subscribe(Events.SHUTDOWN, mutator3, "mutator3")
bus.subscribe(Events.SHUTDOWN, collector, "collector") # Last to collect final state
bus.emit(Events.SHUTDOWN, data={})
# Wait for execution
await asyncio.sleep(0.01)
# Verify final state
assert final_extras["string"] == "test"
assert final_extras["number"] == 42
assert final_extras["list"] == [1, 2, 3, 4]
assert final_extras["dict"] == {"key": "value", "new_key": "new_value"}
assert final_extras["bool"] is True
assert final_extras["nested"]["list"] == [1, 2]
assert final_extras["nested"]["dict"]["inner"] == "value"
@pytest.mark.asyncio
async def test_sync_handler_async_wrapping(self):
"""Test that sync handlers are properly wrapped in async functions to avoid blocking."""
bus = EventBus()
execution_order = []
def slow_sync_handler(event, name, **kwargs): # noqa: ARG001
"""A sync handler that simulates blocking work"""
import time
time.sleep(0.05) # Simulate some work
execution_order.append(f"sync_{name}")
async def fast_async_handler(event, name, **kwargs): # noqa: ARG001
"""A fast async handler"""
execution_order.append(f"async_{name}")
# Subscribe both handlers
bus.subscribe(Events.TEST, slow_sync_handler, "slow_sync")
bus.subscribe(Events.TEST, fast_async_handler, "fast_async")
# Emit should return immediately (non-blocking)
import time
start_time = time.time()
bus.emit(Events.TEST, data={"test": "wrapping"})
emit_time = time.time() - start_time
# Emit should be very fast (< 0.01s) because sync handler is wrapped
assert emit_time < 0.01, f"Emit took too long: {emit_time:.4f}s - sync handler may be blocking"
# Wait for handlers to complete
await asyncio.sleep(0.1)
# Both handlers should have executed
assert len(execution_order) == 2
assert "sync_slow_sync" in execution_order
assert "async_fast_async" in execution_order
@pytest.mark.asyncio
async def test_sync_handler_no_race_condition(self):
"""Test that multiple sync handlers don't have race conditions with loop variables."""
bus = EventBus()
results = []
def handler1(event, name, **kwargs): # noqa: ARG001
results.append(f"handler1_{event.data['id']}")
def handler2(event, name, **kwargs): # noqa: ARG001
results.append(f"handler2_{event.data['id']}")
def handler3(event, name, **kwargs): # noqa: ARG001
results.append(f"handler3_{event.data['id']}")
# Subscribe multiple sync handlers
bus.subscribe(Events.TEST, handler1, "handler1")
bus.subscribe(Events.TEST, handler2, "handler2")
bus.subscribe(Events.TEST, handler3, "handler3")
# Emit multiple events quickly to test for race conditions
for i in range(3):
bus.emit(Events.TEST, data={"id": i})
# Wait for all handlers to complete
await asyncio.sleep(0.05)
# Should have 9 results (3 handlers x 3 events)
assert len(results) == 9
# Each handler should have processed each event with correct data
for i in range(3):
assert f"handler1_{i}" in results
assert f"handler2_{i}" in results
assert f"handler3_{i}" in results
@pytest.mark.asyncio
async def test_mixed_handlers_order(self):
"""Test that mixed sync/async handlers execute properly without blocking."""
bus = EventBus()
execution_times = []
def sync_handler(event, name, **kwargs): # noqa: ARG001
import time
start = time.time()
time.sleep(0.02) # Simulate work
execution_times.append(("sync", time.time() - start))
async def async_handler(event, name, **kwargs): # noqa: ARG001
import time
start = time.time()
await asyncio.sleep(0.01) # Async work
execution_times.append(("async", time.time() - start))
# Subscribe mixed handlers
bus.subscribe(Events.TEST, sync_handler, "sync")
bus.subscribe(Events.TEST, async_handler, "async")
# Emit and measure total time
import time
start_time = time.time()
bus.emit(Events.TEST, data={"test": "mixed"})
emit_time = time.time() - start_time
# Emit should be instant (non-blocking)
assert emit_time < 0.01, f"Emit blocked for {emit_time:.4f}s"
# Wait for execution
await asyncio.sleep(0.1)
# Both handlers should have executed
assert len(execution_times) >= 1, f"Expected at least 1 handler, got {len(execution_times)}: {execution_times}"
# At least the sync handler should have executed with proper timing
sync_results = [t for type_name, t in execution_times if type_name == "sync"]
assert len(sync_results) >= 1, "Sync handler didn't execute"
# Sync handler should have taken approximately 0.02s
sync_time = sync_results[0]
assert 0.015 < sync_time < 0.04, f"Sync handler time unexpected: {sync_time:.4f}s"
class TestEventListener:
"""Test the EventListener class."""
def test_listener_sync_init(self):
"""Test creating EventListener with synchronous callback."""
def sync_callback(event, name, **kwargs): # noqa: ARG001
return f"sync_result_{event.event}"
listener = EventListener("test_listener", sync_callback)
assert listener.name == "test_listener"
assert listener.call_back == sync_callback
assert listener.is_coroutine is False
def test_listener_async_init(self):
"""Test creating EventListener with asynchronous callback."""
async def async_callback(event, name, **kwargs): # noqa: ARG001
return f"async_result_{event.event}"
listener = EventListener("async_listener", async_callback)
assert listener.name == "async_listener"
assert listener.call_back == async_callback
assert listener.is_coroutine is True
@pytest.mark.asyncio
async def test_async_callback(self):
"""Test EventListener with async callback."""
async def async_callback(event, name, **kwargs): # noqa: ARG001
return "async_result"
listener = EventListener("test_async", async_callback)
assert listener.is_coroutine is True
event = Event(event=Events.TEST, data={"test": "data"})
coroutine = listener.handle(event)
# For async callbacks, handle() returns the coroutine directly (NOT awaited)
assert asyncio.iscoroutine(coroutine)
result = await coroutine # Await the coroutine to execute
if asyncio.iscoroutine(result):
result = await result
# The coroutine itself should be the callback return, not awaited by handle()
assert result == (await async_callback(event, "test_async"))
@pytest.mark.asyncio
async def test_event_listener_handle_sync_callback_bug(self):
"""Test EventListener handling with sync callback shows the current bug."""
def sync_callback(event, name, **kwargs): # noqa: ARG001
return f"sync_{event.event}"
listener = EventListener("sync_test", sync_callback)
event = Event(event="test_event", data={})
# Current implementation has a bug with sync callbacks
# It tries to create_task with a non-coroutine, which fails
# This test documents the current behavior that should be fixed
with pytest.raises(TypeError, match="a coroutine was expected"):
await listener.handle(event)
@pytest.mark.asyncio
async def test_event_listener_handle_with_kwargs(self):
"""Test EventListener handling with additional kwargs."""
async def callback_with_kwargs(event, name, extra_param=None, **kwargs): # noqa: ARG001
return {"event": event.event, "extra": extra_param}
listener = EventListener("kwargs_test", callback_with_kwargs)
event = Event(event="kwargs_event", data={})
# For async callbacks, handle returns coroutine directly (the callback call)
coroutine = listener.handle(event, extra_param="test_value")
assert asyncio.iscoroutine(coroutine)
# The coroutine should be equivalent to calling the callback directly
expected_coroutine = callback_with_kwargs(event, "kwargs_test", extra_param="test_value")
# Both should await to the same result
result = await (await coroutine)
expected_result = await expected_coroutine
assert result["event"] == "kwargs_event"
assert result["extra"] == "test_value"
assert result == expected_result
class TestEventBus:
"""Test the EventBus singleton class."""
def setup_method(self):
"""Clear EventBus listeners and reset singleton before each test."""
EventBus._reset_singleton()
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
def test_event_bus_singleton_behavior(self, mock_bg_worker, mock_config):
"""Test that EventBus follows singleton pattern."""
mock_config.get_instance.return_value.debug = False
mock_bg_worker.get_instance.return_value = MagicMock()
bus1 = EventBus()
bus2 = EventBus()
assert bus1 is bus2
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
def test_event_bus_get_instance(self, mock_bg_worker, mock_config):
"""Test EventBus.get_instance() method."""
mock_config.get_instance.return_value.debug = False
mock_bg_worker.get_instance.return_value = MagicMock()
bus1 = EventBus.get_instance()
bus2 = EventBus.get_instance()
assert bus1 is bus2
def test_event_bus_initialization(self):
"""Test EventBus initialization with new clean design."""
# Create EventBus with clean initialization
bus = EventBus()
# Test the initial state is correct
assert bus.debug is False # Default debug state
assert bus._offload is None # Lazy initialization - not loaded until needed
assert bus._listeners == {} # Empty listeners dict
# Test debug enable/disable methods
bus.debug_enable()
assert bus.debug is True
bus.debug_disable()
assert bus.debug is False
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
def test_event_bus_subscribe_single_event(self, mock_bg_worker, mock_config):
"""Test subscribing to a single event."""
mock_config.get_instance.return_value.debug = False
mock_bg_worker.get_instance.return_value = MagicMock()
bus = EventBus()
async def test_callback(event, name, **kwargs): # noqa: ARG001
return "callback_result"
result = bus.subscribe(Events.TEST, test_callback, "test_subscriber")
assert result is bus # Should return self for chaining
assert Events.TEST in bus._listeners
assert any(name == "test_subscriber" for name, _ in bus._listeners[Events.TEST])
listener = next((listener for name, listener in bus._listeners[Events.TEST] if name == "test_subscriber"), None)
assert listener is not None
assert isinstance(listener, EventListener)
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
def test_event_bus_subscribe_multiple_events(self, mock_bg_worker, mock_config):
"""Test subscribing to multiple events."""
mock_config.get_instance.return_value.debug = False
mock_bg_worker.get_instance.return_value = MagicMock()
bus = EventBus()
async def test_callback(event, name, **kwargs): # noqa: ARG001
return "callback_result"
events = [Events.TEST, Events.STARTUP, Events.SHUTDOWN]
bus.subscribe(events, test_callback, "multi_subscriber")
for event in events:
assert event in bus._listeners
assert any(name == "multi_subscriber" for name, _ in bus._listeners[event])
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
def test_event_bus_subscribe_wildcard(self, mock_bg_worker, mock_config):
"""Test subscribing to all events with wildcard."""
mock_config.get_instance.return_value.debug = False
mock_bg_worker.get_instance.return_value = MagicMock()
bus = EventBus()
async def test_callback(event, name, **kwargs): # noqa: ARG001
return "callback_result"
bus.subscribe("*", test_callback, "wildcard_subscriber")
all_events = Events.get_all()
for event in all_events:
assert event in bus._listeners
assert any(name == "wildcard_subscriber" for name, _ in bus._listeners[event])
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
def test_event_bus_subscribe_frontend(self, mock_bg_worker, mock_config):
"""Test subscribing to frontend events."""
mock_config.get_instance.return_value.debug = False
mock_bg_worker.get_instance.return_value = MagicMock()
bus = EventBus()
async def test_callback(event, name, **kwargs): # noqa: ARG001
return "callback_result"
bus.subscribe("frontend", test_callback, "frontend_subscriber")
frontend_events = Events.frontend()
for event in frontend_events:
assert event in bus._listeners
assert any(name == "frontend_subscriber" for name, _ in bus._listeners[event])
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
def test_event_bus_subscribe_invalid_event(self, mock_bg_worker, mock_config):
"""Test subscribing to invalid event."""
mock_config.get_instance.return_value.debug = False
mock_bg_worker.get_instance.return_value = MagicMock()
bus = EventBus()
async def test_callback(event, name, **kwargs): # noqa: ARG001
return "callback_result"
# Should not raise an exception but log an error
result = bus.subscribe("invalid_event", test_callback, "invalid_subscriber")
assert result is bus
assert "invalid_event" not in bus._listeners
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
def test_event_bus_subscribe_auto_generated_name(self, mock_bg_worker, mock_config):
"""Test subscribing without providing name (auto-generated)."""
mock_config.get_instance.return_value.debug = False
mock_bg_worker.get_instance.return_value = MagicMock()
bus = EventBus()
async def test_callback(event, name, **kwargs): # noqa: ARG001
return "callback_result"
bus.subscribe(Events.TEST, test_callback)
assert Events.TEST in bus._listeners
assert len(bus._listeners[Events.TEST]) == 1
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
def test_event_bus_unsubscribe_single_event(self, mock_bg_worker, mock_config):
"""Test unsubscribing from a single event."""
mock_config.get_instance.return_value.debug = False
mock_bg_worker.get_instance.return_value = MagicMock()
bus = EventBus()
async def test_callback(event, name, **kwargs): # noqa: ARG001
return "callback_result"
# First subscribe
bus.subscribe(Events.TEST, test_callback, "test_subscriber")
assert any(name == "test_subscriber" for name, _ in bus._listeners[Events.TEST])
# Then unsubscribe
result = bus.unsubscribe(Events.TEST, "test_subscriber")
assert result is bus
assert not any(name == "test_subscriber" for name, _ in bus._listeners[Events.TEST])
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
def test_event_bus_unsubscribe_multiple_events(self, mock_bg_worker, mock_config):
"""Test unsubscribing from multiple events."""
mock_config.get_instance.return_value.debug = False
mock_bg_worker.get_instance.return_value = MagicMock()
bus = EventBus()
async def test_callback(event, name, **kwargs): # noqa: ARG001
return "callback_result"
events = [Events.TEST, Events.STARTUP]
# Subscribe to multiple events
bus.subscribe(events, test_callback, "multi_subscriber")
# Unsubscribe from multiple events
bus.unsubscribe(events, "multi_subscriber")
for event in events:
assert "multi_subscriber" not in bus._listeners[event]
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
def test_event_bus_unsubscribe_nonexistent(self, mock_bg_worker, mock_config):
"""Test unsubscribing from nonexistent subscription."""
mock_config.get_instance.return_value.debug = False
mock_bg_worker.get_instance.return_value = MagicMock()
bus = EventBus()
# Should not raise an exception
result = bus.unsubscribe(Events.TEST, "nonexistent_subscriber")
assert result is bus
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
def test_event_bus_emit_no_listeners(self, mock_config, mock_bg_worker):
"""Test emit with no listeners."""
# Setup mocks
mock_config_instance = MagicMock()
mock_config_instance.debug = True
mock_config.get_instance.return_value = mock_config_instance
mock_bg_worker_instance = MagicMock()
mock_bg_worker.get_instance.return_value = mock_bg_worker_instance
bus = EventBus()
# Emit event with no listeners - new fire-and-forget API returns None immediately
result = bus.emit(Events.TEST, data={"test": "data"})
# Should return None for fire-and-forget execution
assert result is None
@pytest.mark.asyncio
async def test_event_bus_emit_with_listeners(self):
"""Test emitting event with listeners (fire-and-forget)."""
bus = EventBus()
results = []
async def callback1(event, name, **kwargs): # noqa: ARG001
results.append(f"callback1_{event.event}")
return "result1"
async def callback2(event, name, **kwargs): # noqa: ARG001
results.append(f"callback2_{event.event}")
return "result2"
bus.subscribe(Events.TEST, callback1, "subscriber1")
bus.subscribe(Events.TEST, callback2, "subscriber2")
# emit() returns None immediately (fire-and-forget)
result: None = bus.emit(Events.TEST, data={"test": "data"})
assert result is None
# Give async tasks a moment to execute
await asyncio.sleep(0.01)
# Side effects should have occurred
assert len(results) == 2, f"Expected 2 results, got {len(results)}: {results}"
assert "callback1_test" in results
assert "callback2_test" in results
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
@pytest.mark.asyncio
async def test_event_bus_emit_with_error_in_handler(self, mock_bg_worker, mock_config):
"""Test emitting event when a handler raises an exception."""
mock_config.get_instance.return_value.debug = False
mock_bg_worker.get_instance.return_value = MagicMock()
bus = EventBus()
async def failing_callback(event, name, **kwargs): # noqa: ARG001
msg = "Handler error"
raise ValueError(msg)
async def working_callback(event, name, **kwargs): # noqa: ARG001
return "success"
bus.subscribe(Events.TEST, failing_callback, "failing_subscriber")
bus.subscribe(Events.TEST, working_callback, "working_subscriber")
# Should not raise exception, but log error
bus.emit(Events.TEST, data={"test": "data"})
def test_event_bus_emit_fire_and_forget(self):
"""Test emit() returns None immediately (fire-and-forget)."""
bus = EventBus()
# Should return None for no listeners
result = bus.emit(Events.TEST, data={"test": "data"})
assert result is None
# Should return None even with listeners
async def test_callback(event, name, **kwargs): # noqa: ARG001
return "result"
bus.subscribe(Events.TEST, test_callback, "test_subscriber")
result = bus.emit(Events.TEST, data={"test": "data"})
assert result is None
def test_event_bus_emit_lazy_background_worker(self):
"""Test that BackgroundWorker is only initialized when needed."""
bus = EventBus()
bus._offload = None # Ensure offload is None
# Initially _offload should be None
assert bus._offload is None
async def test_callback(event, name, **kwargs): # noqa: ARG001
return "result"
bus.subscribe(Events.TEST, test_callback, "test_subscriber")
# After emit in no-loop context, _offload should be initialized
bus.emit(Events.TEST, data={"test": "data"})
# Note: This might still be None if running in pytest's event loop
# The lazy initialization happens only when no event loop is detected
@pytest.mark.asyncio
async def test_event_bus_emit_with_kwargs(self):
"""Test emitting event with additional kwargs (fire-and-forget)."""
bus: EventBus = EventBus()
received_kwargs = {}
async def callback_with_kwargs(event, name, extra_param=None, **kwargs): # noqa: ARG001
received_kwargs["extra_param"] = extra_param
received_kwargs["kwargs"] = kwargs
return "result"
bus.subscribe(Events.TEST, callback_with_kwargs, "kwargs_subscriber")
result: None = bus.emit(Events.TEST, data={"test": "data"}, extra_param="test_value", custom_arg="custom")
assert result is None
# Give async tasks a moment to execute
await asyncio.sleep(0.02)
# Side effects should have occurred
assert "extra_param" in received_kwargs, f"received_kwargs: {received_kwargs}"
assert received_kwargs["extra_param"] == "test_value"
assert "kwargs" in received_kwargs, f"received_kwargs: {received_kwargs}"
assert received_kwargs["kwargs"]["custom_arg"] == "custom"
@pytest.mark.asyncio
async def test_event_bus_emit_event_data_defaults(self):
"""Test emitting event with default data handling (fire-and-forget)."""
bus = EventBus()
received_event = None
async def test_callback(event, name, **kwargs): # noqa: ARG001
nonlocal received_event
received_event = event
return "result"
bus.subscribe(Events.TEST, test_callback, "default_subscriber")
# Verify we have a subscriber
assert Events.TEST in bus._listeners
assert len(bus._listeners[Events.TEST]) == 1
# emit() returns None immediately
result = bus.emit(Events.TEST)
assert result is None
# Give async tasks a moment to execute
await asyncio.sleep(0.01)
# Side effects should have occurred
assert received_event is not None, "Callback was not called"
assert received_event.data == {}
assert received_event.title is None
assert received_event.message is None