apprise/tests/test_plugin_glib.py

294 lines
10 KiB
Python

# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# 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