mirror of https://github.com/caronc/apprise
326 lines
12 KiB
Python
326 lines
12 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()"""
|
|
# Re: https://github.com/caronc/apprise/issues/1383
|
|
# This test validates that the NotifyGLib plugin correctly handles a
|
|
# generic exception raised by the `Notify()` method call on a mocked
|
|
# DBus interface. However, it is only meaningful in environments that:
|
|
#
|
|
# 1. Do NOT have PyGObject (`gi`) installed, OR
|
|
# 2. Have `gi`, but without introspection or live bindings activated.
|
|
#
|
|
# When PyGObject is installed and active, the `gi.repository` namespace
|
|
# becomes populated by introspected C-based objects that do not behave
|
|
# like regular Python functions. This causes mock patching via
|
|
# `mocker.patch("gi.repository.Gio.DBusProxy.new_for_bus_sync")` to
|
|
# silently fail or be ignored, as Python's mocking machinery cannot
|
|
# reliably override these introspected symbols.
|
|
#
|
|
# This test exists to ensure coverage of legacy or minimal environments
|
|
# where Apprise's GLib support can still be used through soft mocks,
|
|
# such as CI/CD pipelines or headless test setups where PyGObject is
|
|
# absent or stubbed (as done via `enabled_glib_environment`).
|
|
#
|
|
# Note: In production environments with active PyGObject, exception
|
|
# handling is already tested via `GLib.Error` branches or during actual
|
|
# usage of `NotifyGLib.send()`. This test supplements that by simulating
|
|
# the rare fallback case of a non-GLib-related exception during Notify().
|
|
import gi
|
|
if hasattr(gi, "repository"):
|
|
pytest.skip(
|
|
"pygobject introspection active, test won't behave as expected")
|
|
|
|
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
|