From f1836cff84625a2c21b25c8e76ee697df325eabd Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Sun, 16 Oct 2022 19:43:15 +0200 Subject: [PATCH] Improve testing of `NotifyDBus`, `NotifyGnome`, and `NotifyMacOSX` (#689) --- Dockerfile.py310 | 4 +- Dockerfile.py36 | 4 +- apprise/plugins/NotifyDBus.py | 25 +-- apprise/plugins/NotifyGnome.py | 17 +- apprise/plugins/NotifyMacOSX.py | 14 +- docker-compose.yml | 2 +- test/helpers/module.py | 25 +-- test/test_plugin_glib.py | 270 ++++++++++++++++++++++---------- test/test_plugin_gnome.py | 160 ++++++++++++++----- test/test_plugin_macosx.py | 173 +++++++++++--------- 10 files changed, 458 insertions(+), 236 deletions(-) diff --git a/Dockerfile.py310 b/Dockerfile.py310 index ccfce8b4..e25a55b2 100644 --- a/Dockerfile.py310 +++ b/Dockerfile.py310 @@ -1,8 +1,8 @@ # Base FROM python:3.10-buster RUN apt-get update && \ - apt-get install -y libdbus-1-dev build-essential musl-dev bash -RUN pip install dbus-python + apt-get install -y libdbus-1-dev libgirepository1.0-dev build-essential musl-dev bash +RUN pip install dbus-python PyGObject # Apprise Setup VOLUME ["/apprise"] diff --git a/Dockerfile.py36 b/Dockerfile.py36 index d934b12c..3494df53 100644 --- a/Dockerfile.py36 +++ b/Dockerfile.py36 @@ -1,8 +1,8 @@ # Base FROM python:3.6-buster RUN apt-get update && \ - apt-get install -y libdbus-1-dev build-essential musl-dev bash -RUN pip install dbus-python + apt-get install -y libdbus-1-dev libgirepository1.0-dev build-essential musl-dev bash +RUN pip install dbus-python PyGObject # Apprise Setup VOLUME ["/apprise"] diff --git a/apprise/plugins/NotifyDBus.py b/apprise/plugins/NotifyDBus.py index 2034fe24..07435762 100644 --- a/apprise/plugins/NotifyDBus.py +++ b/apprise/plugins/NotifyDBus.py @@ -26,6 +26,7 @@ from __future__ import absolute_import from __future__ import print_function +import sys from .NotifyBase import NotifyBase from ..common import NotifyImageSize from ..common import NotifyType @@ -77,6 +78,13 @@ try: NOTIFY_DBUS_SUPPORT_ENABLED = ( LOOP_GLIB is not None or LOOP_QT is not None) + # ImportError: When using gi.repository you must not import static modules + # like "gobject". Please change all occurrences of "import gobject" to + # "from gi.repository import GObject". + # See: https://bugzilla.gnome.org/show_bug.cgi?id=709183 + if "gobject" in sys.modules: # pragma: no cover + del sys.modules["gobject"] + try: # The following is required for Image/Icon loading only import gi @@ -272,12 +280,9 @@ class NotifyDBus(NotifyBase): self.x_axis = None self.y_axis = None - # Track whether or not we want to send an image with our notification - # or not. + # Track whether we want to add an image to the notification. self.include_image = include_image - return - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform DBus Notification @@ -286,10 +291,10 @@ class NotifyDBus(NotifyBase): try: session = SessionBus(mainloop=MAINLOOP_MAP[self.schema]) - except DBusException: + except DBusException as e: # Handle exception self.logger.warning('Failed to send DBus notification.') - self.logger.exception('DBus Exception') + self.logger.debug(f'DBus Exception: {e}') return False # If there is no title, but there is a body, swap the two to get rid @@ -342,8 +347,8 @@ class NotifyDBus(NotifyBase): except Exception as e: self.logger.warning( - "Could not load Gnome notification icon ({}): {}" - .format(icon_path, e)) + "Could not load notification icon (%s).", icon_path) + self.logger.debug(f'DBus Exception: {e}') try: # Always call throttle() before any remote execution is made @@ -370,9 +375,9 @@ class NotifyDBus(NotifyBase): self.logger.info('Sent DBus notification.') - except Exception: + except Exception as e: self.logger.warning('Failed to send DBus notification.') - self.logger.exception('DBus Exception') + self.logger.debug(f'DBus Exception: {e}') return False return True diff --git a/apprise/plugins/NotifyGnome.py b/apprise/plugins/NotifyGnome.py index 3fd44040..a9d78702 100644 --- a/apprise/plugins/NotifyGnome.py +++ b/apprise/plugins/NotifyGnome.py @@ -54,8 +54,8 @@ except (ImportError, ValueError, AttributeError): # be in microsoft windows, or we just don't have the python-gobject # library available to us (or maybe one we don't support)? - # Alternativey A ValueError will get thrown upon calling - # gi.require_version() if the requested Notify namespace isn't available + # Alternatively, a `ValueError` will get raised upon calling + # gi.require_version() if the requested Notify namespace isn't available. pass @@ -175,12 +175,9 @@ class NotifyGnome(NotifyBase): if str(urgency).lower().startswith(k)), NotifyGnome.template_args['urgency']['default'])) - # Track whether or not we want to send an image with our notification - # or not. + # Track whether we want to add an image to the notification. self.include_image = include_image - return - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Gnome Notification @@ -214,15 +211,15 @@ class NotifyGnome(NotifyBase): except Exception as e: self.logger.warning( - "Could not load Gnome notification icon ({}): {}" - .format(icon_path, e)) + "Could not load notification icon (%s). ", icon_path) + self.logger.debug(f'Gnome Exception: {e}') notification.show() self.logger.info('Sent Gnome notification.') - except Exception: + except Exception as e: self.logger.warning('Failed to send Gnome notification.') - self.logger.exception('Gnome Exception') + self.logger.debug(f'Gnome Exception: {e}') return False return True diff --git a/apprise/plugins/NotifyMacOSX.py b/apprise/plugins/NotifyMacOSX.py index d612b981..2e12d389 100644 --- a/apprise/plugins/NotifyMacOSX.py +++ b/apprise/plugins/NotifyMacOSX.py @@ -39,13 +39,15 @@ from ..AppriseLocale import gettext_lazy as _ # Default our global support flag NOTIFY_MACOSX_SUPPORT_ENABLED = False + +# TODO: The module will be easier to test without module-level code. if platform.system() == 'Darwin': # Check this is Mac OS X 10.8, or higher major, minor = platform.mac_ver()[0].split('.')[:2] - # Toggle our enabled flag if verion is correct and executable + # Toggle our enabled flag, if version is correct and executable # found. This is done in such a way to provide verbosity to the - # end user so they know why it may or may not work for them. + # end user, so they know why it may or may not work for them. NOTIFY_MACOSX_SUPPORT_ENABLED = \ (int(major) > 10 or (int(major) == 10 and int(minor) >= 8)) @@ -95,6 +97,8 @@ class NotifyMacOSX(NotifyBase): notify_paths = ( '/opt/homebrew/bin/terminal-notifier', '/usr/local/bin/terminal-notifier', + '/usr/bin/terminal-notifier', + '/bin/terminal-notifier', ) # Define object templates @@ -126,17 +130,15 @@ class NotifyMacOSX(NotifyBase): super().__init__(**kwargs) - # Track whether or not we want to send an image with our notification - # or not. + # Track whether we want to add an image to the notification. self.include_image = include_image - # Acquire the notify path + # Acquire the path to the `terminal-notifier` program. self.notify_path = next( # pragma: no branch (p for p in self.notify_paths if os.access(p, os.X_OK)), None) # Set sound object (no q/a for now) self.sound = sound - return def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ diff --git a/docker-compose.yml b/docker-compose.yml index f691bc3a..43d928a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ version: "3.3" services: - test.py35: + test.py36: build: context: . dockerfile: Dockerfile.py36 diff --git a/test/helpers/module.py b/test/helpers/module.py index 8a38b7cf..1892a9a5 100644 --- a/test/helpers/module.py +++ b/test/helpers/module.py @@ -22,24 +22,18 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. - +from importlib import import_module, reload import sys -from importlib import reload - -def reload_plugin(name): +def reload_plugin(name, replace_in=None): """ - Reload builtin plugin module, e.g. `NotifyGnome`. - set filename to plugin to be reloaded (for example NotifyGnome.py) + Reload built-in plugin module, e.g. `NotifyGnome`. - The following libraries need to be reloaded to prevent - TypeError: super(type, obj): obj must be an instance or subtype of type - This is better explained in this StackOverflow post: - https://stackoverflow.com/questions/31363311/\ - any-way-to-manually-fix-operation-of-\ - super-after-ipython-reload-avoiding-ty + Reloading plugin modules is needed when testing module-level code of + notification plugins. + See also https://stackoverflow.com/questions/31363311. """ module_name = f"apprise.plugins.{name}" @@ -53,3 +47,10 @@ def reload_plugin(name): reload(sys.modules['apprise.Apprise']) reload(sys.modules['apprise.utils']) reload(sys.modules['apprise']) + + # Fix reference to new plugin class in given module. + # Needed for updating the module-level import reference like + # `from apprise.plugins.NotifyMacOSX import NotifyMacOSX`. + if replace_in is not None: + mod = import_module(module_name) + setattr(replace_in, name, getattr(mod, name)) diff --git a/test/test_plugin_glib.py b/test/test_plugin_glib.py index bc71ccaf..e4891a20 100644 --- a/test/test_plugin_glib.py +++ b/test/test_plugin_glib.py @@ -22,40 +22,37 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. - +import importlib +import logging import re -import pytest -from unittest import mock - import sys import types +from unittest.mock import Mock, call, ANY + +import pytest + import apprise from helpers import reload_plugin -from importlib import reload # Disable logging for a cleaner testing output -import logging logging.disable(logging.CRITICAL) + +# Skip tests when Python environment does not provide the `dbus` package. if 'dbus' not in sys.modules: - # Environment doesn't allow for dbus pytest.skip("Skipping dbus-python based tests", allow_module_level=True) + from dbus import DBusException # noqa E402 -from apprise.plugins.NotifyDBus import DBusUrgency # noqa E402 +from apprise.plugins.NotifyDBus import DBusUrgency, NotifyDBus # noqa E402 -@mock.patch('dbus.SessionBus') -@mock.patch('dbus.Interface') -@mock.patch('dbus.ByteArray') -@mock.patch('dbus.Byte') -@mock.patch('dbus.mainloop') -def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray, - mock_interface, mock_sessionbus): +def setup_glib_environment(): """ - NotifyDBus() General Tests + Setup a heavily mocked Glib environment. """ + mock_mainloop = Mock() # Our module base gi_name = 'gi' @@ -68,15 +65,15 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray, # for the purpose of testing and capture the handling of the # library when it is missing del sys.modules[gi_name] - reload(sys.modules['apprise.plugins.NotifyDBus']) + importlib.reload(sys.modules['apprise.plugins.NotifyDBus']) # We need to fake our dbus environment for testing purposes since # the gi library isn't available in Travis CI gi = types.ModuleType(gi_name) gi.repository = types.ModuleType(gi_name + '.repository') - mock_pixbuf = mock.Mock() - mock_image = mock.Mock() + mock_pixbuf = Mock() + mock_image = Mock() mock_pixbuf.new_from_file.return_value = mock_image mock_image.get_width.return_value = 100 @@ -92,7 +89,7 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray, gi.repository.GdkPixbuf.Pixbuf = mock_pixbuf # Emulate require_version function: - gi.require_version = mock.Mock( + gi.require_version = Mock( name=gi_name + '.require_version') # Force the fake module to exist @@ -103,18 +100,53 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray, mock_mainloop.qt.DBusQtMainLoop.return_value = True mock_mainloop.qt.DBusQtMainLoop.side_effect = ImportError sys.modules['dbus.mainloop.qt'] = mock_mainloop.qt - reload(sys.modules['apprise.plugins.NotifyDBus']) mock_mainloop.qt.DBusQtMainLoop.side_effect = None mock_mainloop.glib.NativeMainLoop.return_value = True mock_mainloop.glib.NativeMainLoop.side_effect = ImportError() sys.modules['dbus.mainloop.glib'] = mock_mainloop.glib - reload(sys.modules['apprise.plugins.NotifyDBus']) mock_mainloop.glib.DBusGMainLoop.side_effect = None mock_mainloop.glib.NativeMainLoop.side_effect = None - reload_plugin('NotifyDBus') - from apprise.plugins.NotifyDBus import NotifyDBus + # When patching something which has a side effect on the module-level code + # of a plugin, make sure to reload it. + current_module = sys.modules[__name__] + reload_plugin('NotifyDBus', replace_in=current_module) + + +@pytest.fixture +def dbus_environment(mocker): + """ + Fixture to provide a mocked Dbus environment to test case functions. + """ + interface_mock = mocker.patch('dbus.Interface', spec=True, + Notify=Mock()) + mocker.patch('dbus.SessionBus', spec=True, + **{"get_object.return_value": interface_mock}) + + +@pytest.fixture +def glib_environment(): + """ + Fixture to provide a mocked Glib environment to test case functions. + """ + setup_glib_environment() + + +@pytest.fixture +def dbus_glib_environment(dbus_environment, glib_environment): + """ + Fixture to provide a mocked Glib/DBus environment to test case functions. + """ + pass + + +def test_plugin_dbus_general_success(mocker, dbus_glib_environment): + """ + NotifyDBus() general tests + + Test class loading using different arguments, provided via URL. + """ # Create our instance (identify all supported types) obj = apprise.Apprise.instantiate('dbus://', suppress_exceptions=False) @@ -135,10 +167,6 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray, assert obj.url().startswith('glib://_/') obj.duration = 0 - # Test our class loading using a series of arguments - with pytest.raises(TypeError): - NotifyDBus(**{'schema': 'invalid'}) - # Set our X and Y coordinate and try the notification assert NotifyDBus( x_axis=0, y_axis=0, **{'schema': 'dbus'})\ @@ -245,9 +273,21 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray, title='title', body='body', notify_type=apprise.NotifyType.INFO) is True + +def test_plugin_dbus_general_failure(dbus_glib_environment): + """ + Verify a few failure conditions. + """ + with pytest.raises(TypeError): - obj = apprise.Apprise.instantiate( - 'dbus://_/?x=invalid&y=invalid', suppress_exceptions=False) + NotifyDBus(**{'schema': 'invalid'}) + + with pytest.raises(TypeError): + apprise.Apprise.instantiate('dbus://_/?x=invalid&y=invalid', + suppress_exceptions=False) + + +def test_plugin_dbus_parse_configuration(dbus_glib_environment): # Test configuration parsing content = """ @@ -318,104 +358,160 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray, for s in aobj.find(tag='dbus_invalid'): assert s.urgency == DBusUrgency.NORMAL - # If our underlining object throws for whatever rea on, we will - # gracefully fail - mock_notify = mock.Mock() - mock_interface.return_value = mock_notify - mock_notify.Notify.side_effect = AttributeError() - assert obj.notify( - title='', body='body', - notify_type=apprise.NotifyType.INFO) is False - mock_notify.Notify.side_effect = None - # Test our loading of our icon exception; it will still allow the - # notification to be sent - mock_pixbuf.new_from_file.side_effect = AttributeError() +def test_plugin_dbus_missing_icon(mocker, dbus_glib_environment): + """ + Test exception when loading icon; the notification will still be sent. + """ + + # Inject error when loading icon. + gi = importlib.import_module("gi") + gi.repository.GdkPixbuf.Pixbuf.new_from_file.side_effect = \ + AttributeError("Something failed") + + obj = apprise.Apprise.instantiate('dbus://', suppress_exceptions=False) + logger: Mock = mocker.spy(obj, "logger") assert obj.notify( title='title', body='body', notify_type=apprise.NotifyType.INFO) is True - # Undo our change - mock_pixbuf.new_from_file.side_effect = None + assert logger.mock_calls == [ + call.warning('Could not load notification icon (%s). ' + 'Reason: Something failed', ANY), + call.info('Sent DBus notification.'), + ] + + +def test_plugin_dbus_disabled_plugin(dbus_glib_environment): + """ + Verify notification will not be submitted if plugin is disabled. + """ + obj = apprise.Apprise.instantiate('dbus://', suppress_exceptions=False) - # Test our exception handling during initialization - # Toggle our testing for when we can't send notifications because the - # package has been made unavailable to us obj.enabled = False + assert obj.notify( title='title', body='body', notify_type=apprise.NotifyType.INFO) is False - # Test the setting of a the urgency + +def test_plugin_dbus_set_urgency(): + """ + Test the setting of an urgency. + """ NotifyDBus(urgency=0) - # - # We can still notify if the gi library is the only inaccessible - # compontent - # - # Emulate require_version function: +def test_plugin_dbus_gi_missing(dbus_glib_environment): + """ + Verify notification succeeds even if the `gi` package is not available. + """ + + # Make `require_version` function raise an ImportError. + gi = importlib.import_module("gi") gi.require_version.side_effect = ImportError() - reload_plugin('NotifyDBus') - from apprise.plugins.NotifyDBus import NotifyDBus - # Create our instance + # When patching something which has a side effect on the module-level code + # of a plugin, make sure to reload it. + current_module = sys.modules[__name__] + reload_plugin('NotifyDBus', replace_in=current_module) + + # Create the instance. obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False) assert isinstance(obj, NotifyDBus) is True obj.duration = 0 - # Test url() call + # Test url() call. assert isinstance(obj.url(), str) is True - # Our notification succeeds even though the gi library was not loaded + # The notification succeeds even though the gi library was not loaded. assert obj.notify( title='title', body='body', notify_type=apprise.NotifyType.INFO) is True - # Verify this all works in the event a ValueError is also thronw - # out of the call to gi.require_version() - mock_sessionbus.side_effect = DBusException('test') - # Handle Dbus Session Initialization error - assert obj.notify( - title='title', body='body', - notify_type=apprise.NotifyType.INFO) is False +def test_plugin_dbus_gi_require_version_error(dbus_glib_environment): + """ + Verify notification succeeds even if `gi.require_version()` croaks. + """ - # Return side effect to normal - mock_sessionbus.side_effect = None + # Make `require_version` function raise a ValueError. + gi = importlib.import_module("gi") + gi.require_version.side_effect = ValueError("Something failed") - # Emulate require_version function: - gi.require_version.side_effect = ValueError() - reload_plugin('NotifyDBus') - from apprise.plugins.NotifyDBus import NotifyDBus + # When patching something which has a side effect on the module-level code + # of a plugin, make sure to reload it. + current_module = sys.modules[__name__] + reload_plugin('NotifyDBus', replace_in=current_module) - # Create our instance + # Create instance. obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False) assert isinstance(obj, NotifyDBus) is True obj.duration = 0 - # Test url() call + # Test url() call. assert isinstance(obj.url(), str) is True - # Our notification succeeds even though the gi library was not loaded + # The notification succeeds even though the gi library was not loaded. assert obj.notify( title='title', body='body', notify_type=apprise.NotifyType.INFO) is True - # Force a global import error - _session_bus = sys.modules['dbus'] - sys.modules['dbus'] = compile('raise ImportError()', 'dbus', 'exec') - # Reload our modules - reload_plugin('NotifyDBus') +def test_plugin_dbus_module_croaks(mocker, dbus_glib_environment): + """ + Verify plugin is not available when `dbus` module is missing. + """ - # We can no longer instantiate an instance because dbus has been - # officialy marked unavailable and thus the module is marked - # as such + # Make importing `dbus` raise an ImportError. + mocker.patch.dict( + sys.modules, {'dbus': compile('raise ImportError()', 'dbus', 'exec')}) + + # When patching something which has a side effect on the module-level code + # of a plugin, make sure to reload it. + current_module = sys.modules[__name__] + reload_plugin('NotifyDBus', replace_in=current_module) + + # Verify plugin is not available. obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False) assert obj is None - # Since playing with the sys.modules is not such a good idea, - # let's just put our old configuration back: - sys.modules['dbus'] = _session_bus - # Reload our modules - reload_plugin('NotifyDBus') + +def test_plugin_dbus_session_croaks(mocker, dbus_glib_environment): + """ + Verify notification fails if DBus croaks. + """ + + mocker.patch('dbus.SessionBus', side_effect=DBusException('test')) + setup_glib_environment() + + obj = apprise.Apprise.instantiate('dbus://', suppress_exceptions=False) + + # Emulate DBus session initialization error. + assert obj.notify( + title='title', body='body', + notify_type=apprise.NotifyType.INFO) is False + + +def test_plugin_dbus_interface_notify_croaks(mocker): + """ + Fail gracefully if underlying object croaks for whatever reason. + """ + + # Inject an error when invoking `dbus.Interface().Notify()`. + mocker.patch('dbus.SessionBus', spec=True) + mocker.patch('dbus.Interface', spec=True, + Notify=Mock(side_effect=AttributeError("Something failed"))) + setup_glib_environment() + + obj = apprise.Apprise.instantiate('dbus://', suppress_exceptions=False) + assert isinstance(obj, NotifyDBus) is True + + logger: Mock = mocker.spy(obj, "logger") + assert obj.notify( + title='title', body='body', + notify_type=apprise.NotifyType.INFO) is False + assert [ + call.warning('Failed to send DBus notification. ' + 'Reason: Something failed'), + call.exception('DBus Exception') + ] in logger.mock_calls diff --git a/test/test_plugin_gnome.py b/test/test_plugin_gnome.py index 10ec75f3..b1a05c4c 100644 --- a/test/test_plugin_gnome.py +++ b/test/test_plugin_gnome.py @@ -22,24 +22,26 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. - +import importlib +import logging import sys import types from unittest import mock +from unittest.mock import Mock, call, ANY + +import pytest import apprise -from apprise.plugins.NotifyGnome import GnomeUrgency +from apprise.plugins.NotifyGnome import GnomeUrgency, NotifyGnome from helpers import reload_plugin # Disable logging for a cleaner testing output -import logging logging.disable(logging.CRITICAL) -def test_plugin_gnome_general(): +def setup_glib_environment(): """ - NotifyGnome() General Checks - + Setup a heavily mocked Glib environment. """ # Our module base @@ -89,12 +91,30 @@ def test_plugin_gnome_general(): mock_notify.new.return_value = notify_obj mock_pixbuf.new_from_file.return_value = True - reload_plugin('NotifyGnome') - from apprise.plugins.NotifyGnome import NotifyGnome + # When patching something which has a side effect on the module-level code + # of a plugin, make sure to reload it. + current_module = sys.modules[__name__] + reload_plugin('NotifyGnome', replace_in=current_module) + + +@pytest.fixture +def glib_environment(): + """ + Fixture to provide a mocked Glib environment to test case functions. + """ + setup_glib_environment() + + +@pytest.fixture +def obj(glib_environment): + """ + Fixture to provide a mocked Apprise instance. + """ # Create our instance obj = apprise.Apprise.instantiate('gnome://', suppress_exceptions=False) assert obj is not None + assert isinstance(obj, NotifyGnome) is True # Set our duration to 0 to speed up timeouts (for testing) obj.duration = 0 @@ -102,6 +122,14 @@ def test_plugin_gnome_general(): # Check that it found our mocked environments assert obj.enabled is True + return obj + + +def test_plugin_gnome_general_success(obj): + """ + NotifyGnome() general checks + """ + # Test url() call assert isinstance(obj.url(), str) is True @@ -113,9 +141,14 @@ def test_plugin_gnome_general(): assert obj.notify(title='', body='body', notify_type=apprise.NotifyType.INFO) is True + +def test_plugin_gnome_image_success(glib_environment): + """ + Verify using the `image` query argument works as intended. + """ + obj = apprise.Apprise.instantiate( 'gnome://_/?image=True', suppress_exceptions=False) - print("obj:", obj, type(obj)) assert isinstance(obj, NotifyGnome) is True assert obj.notify(title='title', body='body', notify_type=apprise.NotifyType.INFO) is True @@ -126,6 +159,12 @@ def test_plugin_gnome_general(): assert obj.notify(title='title', body='body', notify_type=apprise.NotifyType.INFO) is True + +def test_plugin_gnome_priority(glib_environment): + """ + Verify correctness of the `priority` query argument. + """ + # Test Priority (alias of urgency) obj = apprise.Apprise.instantiate( 'gnome://_/?priority=invalid', suppress_exceptions=False) @@ -148,6 +187,12 @@ def test_plugin_gnome_general(): assert obj.notify(title='title', body='body', notify_type=apprise.NotifyType.INFO) is True + +def test_plugin_gnome_urgency(glib_environment): + """ + Verify correctness of the `urgency` query argument. + """ + # Test Urgeny obj = apprise.Apprise.instantiate( 'gnome://_/?urgency=invalid', suppress_exceptions=False) @@ -170,6 +215,12 @@ def test_plugin_gnome_general(): assert obj.notify(title='title', body='body', notify_type=apprise.NotifyType.INFO) is True + +def test_plugin_gnome_parse_configuration(obj): + """ + Verify configuration parsing works correctly. + """ + # Test configuration parsing content = """ urls: @@ -239,43 +290,82 @@ def test_plugin_gnome_general(): for s in aobj.find(tag='gnome_invalid'): assert s.urgency == GnomeUrgency.NORMAL - # Test our loading of our icon exception; it will still allow the - # notification to be sent - mock_pixbuf.new_from_file.side_effect = AttributeError() + +def test_plugin_gnome_missing_icon(mocker, obj): + """ + Verify the notification will be submitted, even if loading the icon fails. + """ + + # Inject error when loading icon. + gi = importlib.import_module("gi") + gi.repository.GdkPixbuf.Pixbuf.new_from_file.side_effect = \ + AttributeError("Something failed") + + logger: Mock = mocker.spy(obj, "logger") assert obj.notify(title='title', body='body', notify_type=apprise.NotifyType.INFO) is True - # Undo our change - mock_pixbuf.new_from_file.side_effect = None + assert logger.mock_calls == [ + call.warning('Could not load notification icon (%s). ' + 'Reason: Something failed', ANY), + call.info('Sent Gnome notification.'), + ] - # Test our exception handling during initialization - sys.modules['gi.repository.Notify']\ - .Notification.new.return_value = None - sys.modules['gi.repository.Notify']\ - .Notification.new.side_effect = AttributeError() - assert obj.notify(title='title', body='body', - notify_type=apprise.NotifyType.INFO) is False - # Undo our change - sys.modules['gi.repository.Notify']\ - .Notification.new.side_effect = None - - # Toggle our testing for when we can't send notifications because the - # package has been made unavailable to us +def test_plugin_gnome_disabled_plugin(obj): + """ + Verify notification will not be submitted if plugin is disabled. + """ obj.enabled = False assert obj.notify(title='title', body='body', notify_type=apprise.NotifyType.INFO) is False - # Test the setting of a the urgency (through priority keyword) + +def test_plugin_gnome_set_urgency(): + """ + Test the setting of an urgency, through `priority` keyword argument. + """ NotifyGnome(priority=0) - # Verify this all works in the event a ValueError is also thronw - # out of the call to gi.require_version() - # Emulate require_version function: - gi.require_version.side_effect = ValueError() - reload_plugin('NotifyGnome') +def test_plugin_gnome_gi_croaks(): + """ + Verify notification fails when `gi.require_version()` croaks. + """ - # We can now no longer load our instance - # The object internally is marked disabled + # Make `require_version` function raise an error. + try: + gi = importlib.import_module("gi") + except ModuleNotFoundError: + raise pytest.skip("`gi` package not installed") + gi.require_version.side_effect = ValueError("Something failed") + + # When patching something which has a side effect on the module-level code + # of a plugin, make sure to reload it. + current_module = sys.modules[__name__] + reload_plugin('NotifyGnome', replace_in=current_module) + + # Create instance. obj = apprise.Apprise.instantiate('gnome://', suppress_exceptions=False) + + # The notifier is marked disabled. assert obj is None + + +def test_plugin_gnome_notify_croaks(mocker, obj): + """ + Fail gracefully if underlying object croaks for whatever reason. + """ + + # Inject an error when invoking `gi.repository.Notify`. + mocker.patch('gi.repository.Notify.Notification.new', + side_effect=AttributeError("Something failed")) + + logger: Mock = mocker.spy(obj, "logger") + assert obj.notify( + title='title', body='body', + notify_type=apprise.NotifyType.INFO) is False + assert logger.mock_calls == [ + call.warning('Failed to send Gnome notification. ' + 'Reason: Something failed'), + call.exception('Gnome Exception') + ] diff --git a/test/test_plugin_macosx.py b/test/test_plugin_macosx.py index 2673d5cd..023cb3fa 100644 --- a/test/test_plugin_macosx.py +++ b/test/test_plugin_macosx.py @@ -22,49 +22,67 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. - +import logging import os -from unittest import mock +import sys +from unittest.mock import Mock -from helpers import reload_plugin +import pytest import apprise +from apprise.plugins.NotifyMacOSX import NotifyMacOSX +from helpers import reload_plugin -# Disable logging for a cleaner testing output -import logging + +# Disable logging for a cleaner testing output. logging.disable(logging.CRITICAL) -@mock.patch('subprocess.Popen') -@mock.patch('platform.system') -@mock.patch('platform.mac_ver') -def test_plugin_macosx_general(mock_macver, mock_system, mock_popen, tmpdir): +@pytest.fixture +def pretend_macos(mocker): """ - NotifyMacOSX() General Checks - + Fixture to simulate a macOS environment. """ + mocker.patch("platform.system", return_value="Darwin") + mocker.patch("platform.mac_ver", return_value=('10.8', ('', '', ''), '')) - # Create a temporary binary file we can reference - script = tmpdir.join("terminal-notifier") - script.write('') - # Give execute bit - os.chmod(str(script), 0o755) - mock_cmd_response = mock.Mock() + # Reload plugin module, in order to re-run module-level code. + current_module = sys.modules[__name__] + reload_plugin("NotifyMacOSX", replace_in=current_module) - # Set a successful response - mock_cmd_response.returncode = 0 - # Simulate a Mac Environment - mock_system.return_value = 'Darwin' - mock_macver.return_value = ('10.8', ('', '', ''), '') - mock_popen.return_value = mock_cmd_response +@pytest.fixture +def terminal_notifier(mocker, tmp_path): + """ + Fixture for providing a surrogate for the `terminal-notifier` program. + """ + notifier_program = tmp_path.joinpath("terminal-notifier") + notifier_program.write_text('#!/bin/sh\n\necho hello') - # Ensure our environment is loaded with this configuration - reload_plugin('NotifyMacOSX') - from apprise.plugins.NotifyMacOSX import NotifyMacOSX + # Set execute bit. + os.chmod(notifier_program, 0o755) - # Point our object to our new temporary existing file - NotifyMacOSX.notify_paths = (str(script), ) + # Make the notifier use the temporary file instead of `terminal-notifier`. + mocker.patch("apprise.plugins.NotifyMacOSX.NotifyMacOSX.notify_paths", + (str(notifier_program),)) + + yield notifier_program + + +@pytest.fixture +def macos_notify_environment(pretend_macos, terminal_notifier): + """ + Fixture to bundle general test case setup. + + Use this fixture if you don't need access to the individual members. + """ + pass + + +def test_plugin_macosx_general_success(macos_notify_environment): + """ + NotifyMacOSX() general checks + """ obj = apprise.Apprise.instantiate( 'macosx://_/?image=True', suppress_exceptions=False) @@ -103,68 +121,81 @@ def test_plugin_macosx_general(mock_macver, mock_system, mock_popen, tmpdir): assert obj.notify(title='title', body='body', notify_type=apprise.NotifyType.INFO) is True - # If our binary is inacccessible (or not executable), we can - # no longer send our notifications - os.chmod(str(script), 0o644) + +def test_plugin_macosx_terminal_notifier_not_executable( + pretend_macos, terminal_notifier): + """ + When the `terminal-notifier` program is inaccessible or not executable, + we are unable to send notifications. + """ + + obj = apprise.Apprise.instantiate('macosx://', suppress_exceptions=False) + + # Unset the executable bit. + os.chmod(terminal_notifier, 0o644) + assert obj.notify(title='title', body='body', notify_type=apprise.NotifyType.INFO) is False - # Restore permission - os.chmod(str(script), 0o755) - # But now let's disrupt the path location +def test_plugin_macosx_terminal_notifier_invalid(macos_notify_environment): + """ + When the `terminal-notifier` program is wrongly addressed, + notifications should fail. + """ + + obj = apprise.Apprise.instantiate('macosx://', suppress_exceptions=False) + + # Let's disrupt the path location. obj.notify_path = 'invalid_missing-file' assert not os.path.isfile(obj.notify_path) + assert obj.notify(title='title', body='body', notify_type=apprise.NotifyType.INFO) is False - # Test cases where the script just flat out fails - mock_cmd_response.returncode = 1 - obj = apprise.Apprise.instantiate( - 'macosx://', suppress_exceptions=False) + +def test_plugin_macosx_terminal_notifier_croaks( + mocker, macos_notify_environment): + """ + When the `terminal-notifier` program croaks on execution, + notifications should fail. + """ + + # Emulate a failing program. + mocker.patch("subprocess.Popen", return_value=Mock(returncode=1)) + + obj = apprise.Apprise.instantiate('macosx://', suppress_exceptions=False) assert isinstance(obj, NotifyMacOSX) is True assert obj.notify(title='title', body='body', notify_type=apprise.NotifyType.INFO) is False - # Restore script return value - mock_cmd_response.returncode = 0 - # Test case where we simply aren't on a mac - mock_system.return_value = 'Linux' - reload_plugin('NotifyMacOSX') +def test_plugin_macosx_pretend_linux(mocker, pretend_macos): + """ + The notification object is disabled when pretending to run on Linux. + """ - # Point our object to our new temporary existing file - NotifyMacOSX.notify_paths = (str(script), ) + # When patching something which has a side effect on the module-level code + # of a plugin, make sure to reload it. + mocker.patch("platform.system", return_value="Linux") + reload_plugin("NotifyMacOSX") - # Our object is disabled - obj = apprise.Apprise.instantiate( - 'macosx://_/?sound=default', suppress_exceptions=False) + # Our object is disabled. + obj = apprise.Apprise.instantiate('macosx://', suppress_exceptions=False) assert obj is None - # Restore mac environment - mock_system.return_value = 'Darwin' - # Now we must be Mac OS v10.8 or higher... - mock_macver.return_value = ('10.7', ('', '', ''), '') - reload_plugin('NotifyMacOSX') +@pytest.mark.parametrize("macos_version", ["9.12", "10.7"]) +def test_plugin_macosx_pretend_old_macos(mocker, macos_version): + """ + The notification object is disabled when pretending to run on older macOS. + """ - # Point our object to our new temporary existing file - NotifyMacOSX.notify_paths = (str(script), ) + # When patching something which has a side effect on the module-level code + # of a plugin, make sure to reload it. + mocker.patch("platform.mac_ver", + return_value=(macos_version, ('', '', ''), '')) + reload_plugin("NotifyMacOSX") - obj = apprise.Apprise.instantiate( - 'macosx://_/?sound=default', suppress_exceptions=False) - assert obj is None - - # A newer environment to test edge case where this is tested - mock_macver.return_value = ('9.12', ('', '', ''), '') - reload_plugin('NotifyMacOSX') - - # Point our object to our new temporary existing file - NotifyMacOSX.notify_paths = (str(script), ) - - # This is just to test that the the minor (in this case .12) - # is only weighed with respect to the major number as wel - # with respect to the versioning - obj = apprise.Apprise.instantiate( - 'macosx://_/?sound=default', suppress_exceptions=False) + obj = apprise.Apprise.instantiate('macosx://', suppress_exceptions=False) assert obj is None