mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
fix: plugin event dispatch crashes on first disabled plugin (#1231)
trigger_event in apps/connect/utils.py iterates pm.list_plugins() and
on disabled plugins emits a debug log that accesses dict items as if
they were attributes:
logger.debug(f"Skipping disabled plugin id={plugin.key} name={plugin.name}")
Python evaluates f-string arguments eagerly even when the logger
discards the message at INFO level, so this raises AttributeError on
the first disabled plugin encountered. The exception bubbles out of
trigger_event with no try/except in the loop, aborting dispatch for
every plugin sorted after the disabled one.
Effect for users: any plugin subscribed to events via `events: [...]`
on an action silently never receives events whenever any
alphabetically-earlier plugin is disabled. Manual button actions still
work, masking the failure as a plugin bug rather than a dispatch one.
Fix: replace the two attribute accesses with dict access. Add a
regression test under apps/connect/tests/ that mocks PluginManager to
return [disabled, enabled-with-events], asserts the enabled plugin's
action is dispatched, and a sanity check that non-matching events
aren't dispatched. Verified the test fails against the original code
with the expected AttributeError and passes with the fix.
This commit is contained in:
parent
84fbbfa3c4
commit
65e7b55200
3 changed files with 103 additions and 1 deletions
0
apps/connect/tests/__init__.py
Normal file
0
apps/connect/tests/__init__.py
Normal file
102
apps/connect/tests/test_trigger_event.py
Normal file
102
apps/connect/tests/test_trigger_event.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""
|
||||
Regression tests for the plugin event dispatch loop in apps.connect.utils.
|
||||
|
||||
Previously, trigger_event accessed `plugin.key` / `plugin.name` (attribute
|
||||
access) on dict items returned by PluginManager.list_plugins(). On the
|
||||
first disabled plugin encountered, that f-string raised AttributeError —
|
||||
and because Python evaluates f-string arguments eagerly even when the
|
||||
logger discards the message at INFO level, the exception bubbled out of
|
||||
trigger_event with no try/except. Any enabled plugin sorted after a
|
||||
disabled one then silently received zero events.
|
||||
|
||||
These tests guard against regression by:
|
||||
|
||||
1. Feeding trigger_event a plugins list with a disabled plugin BEFORE an
|
||||
enabled-with-events plugin and asserting the enabled plugin's action
|
||||
is still dispatched.
|
||||
2. Sanity-checking that actions without a matching `events` entry are
|
||||
not dispatched.
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
|
||||
def _empty_subscription_chain():
|
||||
"""Mock the EventSubscription.objects.filter(...).select_related(...)
|
||||
chain to yield no subscriptions, so trigger_event proceeds straight to
|
||||
the plugin loop."""
|
||||
empty_qs = MagicMock()
|
||||
empty_qs.count.return_value = 0
|
||||
empty_qs.__iter__ = lambda self: iter([])
|
||||
chain = MagicMock()
|
||||
chain.select_related.return_value = empty_qs
|
||||
return chain
|
||||
|
||||
|
||||
class TriggerEventDispatchTests(SimpleTestCase):
|
||||
def _run_trigger_event(self, plugins, event_name, payload):
|
||||
pm = MagicMock()
|
||||
pm.list_plugins.return_value = plugins
|
||||
with patch(
|
||||
"apps.connect.utils.PluginManager.get", return_value=pm
|
||||
), patch(
|
||||
"apps.connect.utils.EventSubscription.objects.filter",
|
||||
return_value=_empty_subscription_chain(),
|
||||
):
|
||||
from apps.connect.utils import trigger_event
|
||||
|
||||
trigger_event(event_name, payload)
|
||||
return pm
|
||||
|
||||
def test_disabled_plugin_does_not_abort_dispatch_for_later_enabled_plugin(self):
|
||||
"""The original bug: AttributeError on a disabled plugin's debug log
|
||||
aborted the loop, so any plugin iterated AFTER a disabled one never
|
||||
received events."""
|
||||
plugins = [
|
||||
{
|
||||
"key": "disabled-plugin",
|
||||
"name": "Disabled Plugin",
|
||||
"enabled": False,
|
||||
"actions": [],
|
||||
},
|
||||
{
|
||||
"key": "enabled-plugin",
|
||||
"name": "Enabled Plugin",
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
{"id": "on_event", "events": ["channel_start"]},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
pm = self._run_trigger_event(
|
||||
plugins, "channel_start", {"channel_name": "TEST"}
|
||||
)
|
||||
|
||||
pm.run_action.assert_called_once_with(
|
||||
"enabled-plugin",
|
||||
"on_event",
|
||||
{"event": "channel_start", "payload": {"channel_name": "TEST"}},
|
||||
)
|
||||
|
||||
def test_action_without_matching_event_is_not_dispatched(self):
|
||||
"""Sanity check: actions whose `events` list doesn't include the
|
||||
fired event, or which have no `events` key at all, are skipped."""
|
||||
plugins = [
|
||||
{
|
||||
"key": "plugin",
|
||||
"name": "Plugin",
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
{"id": "on_event", "events": ["channel_stop"]},
|
||||
{"id": "manual_button"}, # no events key
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
pm = self._run_trigger_event(
|
||||
plugins, "channel_start", {"channel_name": "TEST"}
|
||||
)
|
||||
|
||||
pm.run_action.assert_not_called()
|
||||
|
|
@ -99,7 +99,7 @@ def trigger_event(event_name, payload):
|
|||
logger.debug(f"Checking {len(plugins)} plugins for event '{event_name}'")
|
||||
for plugin in plugins:
|
||||
if not plugin["enabled"]:
|
||||
logger.debug(f"Skipping disabled plugin id={plugin.key} name={plugin.name}")
|
||||
logger.debug(f"Skipping disabled plugin id={plugin['key']} name={plugin['name']}")
|
||||
continue
|
||||
|
||||
logger.debug(json.dumps(plugin))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue