# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2025, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import sys import types from unittest.mock import Mock, call from helpers import reload_plugin import pytest import apprise from apprise.plugins.glib import GLibUrgency, NotifyGLib # Disable logging output during testing logging.disable(logging.CRITICAL) @pytest.fixture def enabled_glib_environment(monkeypatch): """ Fully mocked GI/GLib/Gio/GdkPixbuf environment for local and CI. """ # Step 1: Fake gi and repository gi = types.ModuleType("gi") gi.require_version = Mock() fake_variant = Mock(name="Variant") fake_error = type("GLibError", (Exception,), {}) fake_pixbuf = Mock() fake_image = Mock() fake_pixbuf.new_from_file.return_value = fake_image fake_image.get_width.return_value = 100 fake_image.get_height.return_value = 100 fake_image.get_rowstride.return_value = 1 fake_image.get_has_alpha.return_value = False fake_image.get_bits_per_sample.return_value = 8 fake_image.get_n_channels.return_value = 1 fake_image.get_pixels.return_value = b"" gi.repository = types.SimpleNamespace( Gio=Mock(), GLib=types.SimpleNamespace(Variant=fake_variant, Error=fake_error), GdkPixbuf=types.SimpleNamespace(Pixbuf=fake_pixbuf), ) # Step 2: Inject into sys.modules sys.modules["gi"] = gi sys.modules["gi.repository"] = gi.repository # Step 3: Reload plugin with all mocks in place reload_plugin("glib") def test_plugin_glib_gdkpixbuf_attribute_error(monkeypatch): """Simulate AttributeError from importing GdkPixbuf""" # Create gi module gi = types.ModuleType("gi") # Create gi.repository mock, but DO NOT include GdkPixbuf gi.repository = types.SimpleNamespace( Gio=Mock(), GLib=types.SimpleNamespace( Variant=Mock(), Error=type("GLibError", (Exception,), {}) ), # GdkPixbuf missing entirely triggers AttributeError ) def fake_require_version(name, version): if name == "GdkPixbuf": # Simulate success in require_version return return gi.require_version = Mock(side_effect=fake_require_version) # Inject into sys.modules sys.modules["gi"] = gi sys.modules["gi.repository"] = gi.repository # Trigger the plugin reload with our patched environment reload_plugin("glib") from apprise.plugins import glib as plugin_glib assert plugin_glib.NOTIFY_GLIB_IMAGE_SUPPORT is False def test_plugin_glib_basic_notify(enabled_glib_environment): """Basic notification path""" obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) assert isinstance(obj, NotifyGLib) assert obj.notify("body", title="title") is True def test_plugin_glib_url_includes_coordinates(enabled_glib_environment): """Test that x/y coordinates appear in the rendered URL.""" obj = apprise.Apprise.instantiate( "glib://_/?x=7&y=9", suppress_exceptions=False) url = obj.url(privacy=False) assert "x=7" in url assert "y=9" in url def test_plugin_glib_icon_fails_gracefully(mocker, enabled_glib_environment): """Simulate image load failure""" import gi gi.repository.GdkPixbuf.Pixbuf.new_from_file.side_effect = \ AttributeError("fail") obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) spy = mocker.spy(obj, "logger") assert obj.notify("msg", title="t") is True assert any("Could not load notification icon" in str(x) for x in spy.warning.call_args_list) def test_plugin_glib_send_raises_glib_error(mocker, enabled_glib_environment): """Simulate GLib.Error in DBusProxy creation""" import gi gi.repository.Gio.DBusProxy.new_for_bus_sync.side_effect = \ gi.repository.GLib.Error("fail") obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) assert obj.notify("fail test") is False def test_plugin_glib_send_raises_generic(mocker, enabled_glib_environment): """Simulate generic error in gio_iface.Notify()""" fake_iface = Mock() fake_iface.Notify.side_effect = RuntimeError("boom") mocker.patch( "gi.repository.Gio.DBusProxy.new_for_bus_sync", return_value=fake_iface) obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) logger = mocker.spy(obj, "logger") assert obj.notify("boom", title="fail") is False logger.warning.assert_called_with("Failed to send GLib/Gio notification.") def test_plugin_glib_disabled(mocker, enabled_glib_environment): """Test disabled plugin returns False on notify()""" obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) obj.enabled = False assert obj.notify("x") is False def test_plugin_glib_invalid_coords(): """Invalid x/y coordinates cause TypeError""" with pytest.raises(TypeError): NotifyGLib(x_axis="bad", y_axis="1") with pytest.raises(TypeError): NotifyGLib(x_axis="1", y_axis="bad") def test_plugin_glib_urgency_parsing(): """Urgency variants map correctly""" assert NotifyGLib(urgency="high").urgency == GLibUrgency.HIGH assert NotifyGLib(urgency="invalid").urgency == GLibUrgency.NORMAL assert NotifyGLib(urgency="2").urgency == GLibUrgency.HIGH assert NotifyGLib(urgency=0).urgency == GLibUrgency.LOW def test_plugin_glib_parse_url_fields(): url = "glib://_/?x=5&y=5&image=no&priority=high" result = NotifyGLib.parse_url(url) assert result["x_axis"] == "5" assert result["y_axis"] == "5" assert result["include_image"] is False assert result["urgency"] == "high" def test_plugin_glib_xy_axis_applied_to_variant(enabled_glib_environment): """Ensure x/y values are added to GLib.Variant payload.""" obj = apprise.Apprise.instantiate( "glib://_/?x=5&y=10", suppress_exceptions=False) # Patch GLib.Variant to track calls import gi spy_variant = Mock(wraps=gi.repository.GLib.Variant) gi.repository.GLib.Variant = spy_variant assert obj.notify("Test with coords", title="xy") is True # Check x and y were added to meta_payload assert call("i", 5) in spy_variant.mock_calls assert call("i", 10) in spy_variant.mock_calls def test_plugin_glib_no_image_support(monkeypatch, enabled_glib_environment): """Simulate GdkPixbuf unavailable""" monkeypatch.setattr( "apprise.plugins.glib.NOTIFY_GLIB_IMAGE_SUPPORT", False) obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) assert obj.notify("no image") is True def test_plugin_glib_url_redaction(enabled_glib_environment): """url() privacy mode redacts safely""" obj = apprise.Apprise.instantiate( "glib://_/?image=no&urgency=high", suppress_exceptions=False) url = obj.url(privacy=True) assert "image=" in url assert "urgency=" in url assert url.startswith("glib://_/") def test_plugin_glib_require_version_importerror(monkeypatch): """Simulate gi.require_version() raising ImportError""" gi = types.ModuleType("gi") gi.require_version = Mock(side_effect=ImportError("no gio")) sys.modules["gi"] = gi reload_plugin("glib") obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) assert not isinstance(obj, NotifyGLib) def test_plugin_glib_require_version_valueerror(monkeypatch): """Simulate gi.require_version() raising ValueError without reload crash.""" import gi import apprise.plugins.glib as plugin_glib # Patch require_version after import monkeypatch.setattr( gi, "require_version", Mock(side_effect=ValueError("fail"))) # Re-evaluate plugin support logic manually try: gi.require_version("Gio", "2.0") except Exception: plugin_glib.NOTIFY_GLIB_SUPPORT_ENABLED = False plugin_glib.NotifyGLib.enabled = False # Confirm plugin is now marked disabled assert not plugin_glib.NotifyGLib.enabled # Apprise will skip this plugin obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) assert not isinstance(obj, plugin_glib.NotifyGLib) def test_plugin_glib_gdkpixbuf_require_version_valueerror(monkeypatch): """Simulate gi.require_version('GdkPixbuf', ...) raising ValueError""" # Step 1: Mock GI gi = types.ModuleType("gi") gi.repository = types.ModuleType("gi.repository") def fake_require_version(name: str, version: str) -> None: if name == "GdkPixbuf": raise ValueError("GdkPixbuf unavailable") gi.require_version = Mock(side_effect=fake_require_version) # Step 2: Patch into sys.modules sys.modules["gi"] = gi sys.modules["gi.repository"] = gi.repository # Step 3: Reload plugin to trigger branch reload_plugin("glib") # Step 4: Confirm GdkPixbuf image support was not enabled from apprise.plugins import glib as plugin_glib assert plugin_glib.NOTIFY_GLIB_IMAGE_SUPPORT is False