mirror of
https://github.com/caronc/apprise.git
synced 2025-12-15 10:04:06 +08:00
968 lines
30 KiB
Python
968 lines
30 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.
|
|
|
|
# Disable logging for a cleaner testing output
|
|
import json
|
|
import logging
|
|
from unittest import mock
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from helpers import AppriseURLTester
|
|
|
|
from apprise.plugins.dot import NotifyDot
|
|
|
|
|
|
class DummyAttachment:
|
|
def __init__(self, payload="ZmFjZQ=="):
|
|
self._payload = payload
|
|
|
|
def base64(self):
|
|
return self._payload
|
|
|
|
|
|
logging.disable(logging.CRITICAL)
|
|
|
|
# Our Testing URLs
|
|
apprise_url_tests = (
|
|
(
|
|
"dot://",
|
|
{
|
|
# No API key or device ID
|
|
"instance": None,
|
|
},
|
|
),
|
|
(
|
|
"dot://@",
|
|
{
|
|
# No device ID
|
|
"instance": None,
|
|
},
|
|
),
|
|
(
|
|
"dot://apikey@",
|
|
{
|
|
# No device ID
|
|
"instance": None,
|
|
},
|
|
),
|
|
(
|
|
"dot://@device_id",
|
|
{
|
|
# No API key
|
|
"instance": NotifyDot,
|
|
# Expected notify() response False (because we won't be able
|
|
# to actually notify anything if no api key was specified
|
|
"notify_response": False,
|
|
},
|
|
),
|
|
(
|
|
"dot://apikey@device_id/text/",
|
|
{
|
|
# Everything is okay (text mode)
|
|
"instance": NotifyDot,
|
|
# Our expected url(privacy=True) startswith() response:
|
|
"privacy_url": "dot://****@device_id/text/",
|
|
},
|
|
),
|
|
(
|
|
"dot://apikey@device_id/text/?refresh=no",
|
|
{
|
|
# Disable refresh now
|
|
"instance": NotifyDot,
|
|
},
|
|
),
|
|
(
|
|
"dot://apikey@device_id/text/?signature=test_signature",
|
|
{
|
|
# With signature
|
|
"instance": NotifyDot,
|
|
},
|
|
),
|
|
(
|
|
"dot://apikey@device_id/text/?link=https://example.com",
|
|
{
|
|
# With link
|
|
"instance": NotifyDot,
|
|
},
|
|
),
|
|
(
|
|
"dot://apikey@device_id/image/?link=https://example.com&border=1&dither_type=ORDERED&dither_kernel=ATKINSON",
|
|
{
|
|
# Image mode without payload should fail
|
|
"instance": NotifyDot,
|
|
"notify_response": False,
|
|
"attach_response": True,
|
|
},
|
|
),
|
|
(
|
|
"dot://apikey@device_id/image/?image=ZmFrZUJhc2U2NA==&link=https://example.com&border=1&dither_type=DIFFUSION&dither_kernel=FLOYD_STEINBERG",
|
|
{
|
|
# Image mode with provided image data
|
|
"instance": NotifyDot,
|
|
# Our expected url(privacy=True) startswith() response:
|
|
"privacy_url": "dot://****@device_id/image/",
|
|
},
|
|
),
|
|
(
|
|
"dot://apikey@device_id/text/",
|
|
{
|
|
"instance": NotifyDot,
|
|
# throw a bizarre code forcing us to fail to look it up
|
|
"response": False,
|
|
"requests_response_code": 999,
|
|
},
|
|
),
|
|
(
|
|
"dot://apikey@device_id/text/",
|
|
{
|
|
"instance": NotifyDot,
|
|
# Throws a series of i/o exceptions with this flag
|
|
# is set and tests that we gracefully handle them
|
|
"test_requests_exceptions": True,
|
|
},
|
|
),
|
|
(
|
|
"dot://apikey@device_id/unknown/",
|
|
{
|
|
# Unknown mode defaults back to text
|
|
"instance": NotifyDot,
|
|
"privacy_url": "dot://****@device_id/text/",
|
|
},
|
|
),
|
|
)
|
|
|
|
|
|
def test_plugin_dot_urls():
|
|
"""NotifyDot() Apprise URLs."""
|
|
|
|
# Run our general tests
|
|
AppriseURLTester(tests=apprise_url_tests).run_all()
|
|
|
|
|
|
def test_notify_dot_image_mode_requires_image():
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="image")
|
|
assert dot.notify(title="x", body="y") is False
|
|
|
|
|
|
def test_notify_dot_image_mode_with_attachment():
|
|
"""Test image mode uses first attachment when no image_data provided."""
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
mode="image",
|
|
link="https://example.com",
|
|
border=1,
|
|
dither_type="ORDERED",
|
|
dither_kernel="ATKINSON",
|
|
)
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(
|
|
body="payload", title="title", attach=[DummyAttachment("YmFzZTY0")]
|
|
)
|
|
|
|
assert mock_post.called
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
assert payload["image"] == "YmFzZTY0"
|
|
assert payload["deviceId"] == "device"
|
|
|
|
|
|
def test_notify_dot_image_mode_with_existing_image_data():
|
|
"""Test image mode ignores attachment when image_data is provided."""
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
mode="image",
|
|
image_data="existing_image_data",
|
|
)
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(
|
|
body="test",
|
|
title="test",
|
|
attach=[DummyAttachment("attachment_data")],
|
|
)
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
# Should use existing image_data, not attachment
|
|
assert payload["image"] == "existing_image_data"
|
|
|
|
|
|
def test_notify_dot_text_mode_with_existing_icon():
|
|
"""Test text mode with existing icon (attachment should be ignored)."""
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
signature="footer",
|
|
icon="aW5jb24=",
|
|
)
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(
|
|
title="hello",
|
|
body="world",
|
|
attach=[DummyAttachment("attachment_icon")],
|
|
)
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
assert "image" not in payload
|
|
assert payload["deviceId"] == "device"
|
|
assert payload["message"] == "world"
|
|
# Should use existing icon, not attachment
|
|
assert payload["icon"] == "aW5jb24="
|
|
|
|
|
|
def test_notify_dot_text_mode_uses_attachment_as_icon():
|
|
"""Test text mode uses first attachment as icon when no icon provided."""
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
signature="footer",
|
|
)
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(
|
|
title="hello",
|
|
body="world",
|
|
attach=[DummyAttachment("attachment_icon_data")],
|
|
)
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
assert payload["deviceId"] == "device"
|
|
assert payload["message"] == "world"
|
|
# Should use attachment as icon
|
|
assert payload["icon"] == "attachment_icon_data"
|
|
|
|
|
|
def test_notify_dot_text_mode_multiple_attachments_warning():
|
|
"""Test text mode warns when multiple attachments are provided."""
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
)
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
with (
|
|
mock.patch("requests.post", return_value=response) as mock_post,
|
|
mock.patch.object(dot.logger, "warning") as mock_warning,
|
|
):
|
|
assert dot.send(
|
|
title="hello",
|
|
body="world",
|
|
attach=[
|
|
DummyAttachment("first"),
|
|
DummyAttachment("second"),
|
|
],
|
|
)
|
|
# Should warn about multiple attachments
|
|
mock_warning.assert_called_once()
|
|
assert "Multiple attachments" in str(mock_warning.call_args)
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
# Should use first attachment only
|
|
assert payload["icon"] == "first"
|
|
|
|
|
|
def test_notify_dot_url_generation():
|
|
text_dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
signature="sig",
|
|
icon="aW5jb24=",
|
|
)
|
|
text_url = text_dot.url()
|
|
parsed = urlparse(text_url)
|
|
assert parsed.path.endswith("/text/")
|
|
query = parse_qs(parsed.query)
|
|
assert query["refresh"] == ["yes"]
|
|
assert query["signature"] == ["sig"]
|
|
|
|
image_dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
mode="image",
|
|
image_data="aW1hZ2U=",
|
|
link="https://example.com",
|
|
border=1,
|
|
dither_type="ORDERED",
|
|
dither_kernel="ATKINSON",
|
|
)
|
|
image_url = image_dot.url()
|
|
parsed_image = urlparse(image_url)
|
|
assert parsed_image.path.endswith("/image/")
|
|
image_query = parse_qs(parsed_image.query)
|
|
assert image_query["image"] == ["aW1hZ2U="]
|
|
assert image_query["border"] == ["1"]
|
|
|
|
|
|
def test_notify_dot_parse_url_mode_and_image():
|
|
result = NotifyDot.parse_url(
|
|
"dot://token@device/image/?image=Zm9vYmFy&link=https://example.com"
|
|
)
|
|
assert result["mode"] == "image"
|
|
assert result["image_data"] == "Zm9vYmFy"
|
|
assert result["link"] == "https://example.com"
|
|
|
|
fallback = NotifyDot.parse_url("dot://token@device/unknown/?refresh=no")
|
|
assert fallback["mode"] == "text"
|
|
assert fallback["refresh_now"] is False
|
|
|
|
|
|
def test_notify_dot_invalid_mode():
|
|
"""Test invalid mode handling."""
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="invalid_mode")
|
|
assert dot.mode == "text"
|
|
|
|
dot = NotifyDot(apikey="token", device_id="device", mode=123)
|
|
assert dot.mode == "text"
|
|
|
|
|
|
def test_notify_dot_image_data_in_text_mode():
|
|
"""Test that image_data is ignored in text mode."""
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
mode="text",
|
|
image_data="somebase64",
|
|
)
|
|
assert dot.image_data is None
|
|
|
|
|
|
def test_notify_dot_text_mode_with_title_and_body():
|
|
"""Test text mode with title and body."""
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
)
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
# Test with title and body provided at runtime
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(body="test_body", title="test_title")
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
assert payload["message"] == "test_body"
|
|
assert payload["title"] == "test_title"
|
|
|
|
|
|
def test_notify_dot_no_device_id():
|
|
"""Test behavior when device_id is missing."""
|
|
dot = NotifyDot(apikey="token", device_id=None)
|
|
assert dot.notify(title="test", body="test") is False
|
|
assert len(dot) == 0
|
|
|
|
|
|
def test_notify_dot_parse_url_with_all_params():
|
|
"""Test parse_url with all parameters."""
|
|
result = NotifyDot.parse_url(
|
|
"dot://apikey@device/image/?refresh=yes&signature=sig&icon=icon_b64"
|
|
"&link=https://example.com&border=1&dither_type=ORDERED"
|
|
"&dither_kernel=ATKINSON&image=img_b64"
|
|
)
|
|
assert result["mode"] == "image"
|
|
assert result["refresh_now"] is True
|
|
assert result["signature"] == "sig"
|
|
assert result["icon"] == "icon_b64"
|
|
assert result["link"] == "https://example.com"
|
|
assert result["border"] == 1
|
|
assert result["dither_type"] == "ORDERED"
|
|
assert result["dither_kernel"] == "ATKINSON"
|
|
assert result["image_data"] == "img_b64"
|
|
|
|
|
|
def test_notify_dot_url_identifier():
|
|
"""Test url_identifier property."""
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="image")
|
|
identifier = dot.url_identifier
|
|
assert identifier == ("dot", "token", "device", "image")
|
|
|
|
|
|
def test_notify_dot_image_mode_with_failed_attachment():
|
|
"""Test image mode when attachment fails to convert."""
|
|
|
|
class FailedAttachment:
|
|
def base64(self):
|
|
raise Exception("Conversion failed")
|
|
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="image")
|
|
# Should fail when no valid image data is available
|
|
assert dot.notify(
|
|
title="test", body="test", attach=[FailedAttachment()]
|
|
) is False
|
|
|
|
|
|
def test_notify_dot_url_generation_defaults():
|
|
"""Test URL generation with default values."""
|
|
dot = NotifyDot(apikey="token", device_id="device")
|
|
url = dot.url()
|
|
assert "refresh=yes" in url
|
|
assert "/text/" in url
|
|
|
|
# Test image mode URL with non-default values
|
|
dot_image = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
mode="image",
|
|
image_data="img",
|
|
dither_type="ORDERED",
|
|
dither_kernel="ATKINSON",
|
|
)
|
|
url_image = dot_image.url()
|
|
assert "/image/" in url_image
|
|
assert "dither_type=ORDERED" in url_image
|
|
assert "dither_kernel=ATKINSON" in url_image
|
|
|
|
|
|
def test_notify_dot_image_mode_with_multiple_attachments():
|
|
"""Test image mode with multiple attachments (only first is used)."""
|
|
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="image")
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
# Multiple attachments provided, only first should be used
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(
|
|
body="test",
|
|
title="test",
|
|
attach=[
|
|
DummyAttachment("first_attachment"),
|
|
DummyAttachment("second_attachment"),
|
|
],
|
|
)
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
# Should use first attachment only
|
|
assert payload["image"] == "first_attachment"
|
|
|
|
|
|
def test_notify_dot_text_mode_without_title():
|
|
"""Test text mode without title (title is optional)."""
|
|
dot = NotifyDot(apikey="token", device_id="device")
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
# Test with empty title - title should not be in payload
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(body="test message", title="")
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
# Title should not be in payload when empty
|
|
assert "title" not in payload
|
|
assert payload["message"] == "test message"
|
|
|
|
|
|
def test_notify_dot_url_generation_with_link():
|
|
"""Test URL generation with link in text mode."""
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
link="https://example.com",
|
|
)
|
|
url = dot.url()
|
|
assert "link=" in url
|
|
|
|
# Test image mode with border=0 (should not appear in URL for default)
|
|
dot_image = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
mode="image",
|
|
image_data="img",
|
|
border=0,
|
|
)
|
|
url_image = dot_image.url()
|
|
assert "border=0" in url_image
|
|
|
|
|
|
def test_notify_dot_title_handling():
|
|
"""Test title handling in text mode."""
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
)
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
# Test 1: With provided title
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(body="test", title="provided_title")
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
assert payload["title"] == "provided_title"
|
|
|
|
# Test 2: Without provided title, should not include title in payload
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(body="test", title="")
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
# Title should not be in payload when empty
|
|
assert "title" not in payload
|
|
|
|
|
|
def test_notify_dot_image_mode_no_border():
|
|
"""Test image mode with border=None to skip border in payload."""
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
mode="image",
|
|
image_data="base64img",
|
|
)
|
|
# Manually set border to None
|
|
dot.border = None
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(body="test", title="test")
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
# Border should not be in payload when None
|
|
assert "border" not in payload
|
|
|
|
|
|
def test_notify_dot_image_mode_no_dither():
|
|
"""Test image mode with no dither_type/dither_kernel."""
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
mode="image",
|
|
image_data="base64img",
|
|
)
|
|
# Manually set to None to test the conditional branches
|
|
dot.dither_type = None
|
|
dot.dither_kernel = None
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(body="test", title="test")
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
# Dither fields should not be in payload when None
|
|
assert "ditherType" not in payload
|
|
assert "ditherKernel" not in payload
|
|
|
|
|
|
def test_notify_dot_text_mode_no_optional_fields():
|
|
"""Test text mode with no signature, icon, or link."""
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
)
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(body="test body", title="test title")
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
assert "signature" not in payload
|
|
assert "icon" not in payload
|
|
# Link should not be in payload when not set
|
|
assert payload.get("link") is None or "link" not in payload
|
|
|
|
|
|
def test_notify_dot_url_generation_without_defaults():
|
|
"""Test URL generation without default dither values."""
|
|
# Test with DIFFUSION (default) - should not appear in URL
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
mode="image",
|
|
image_data="img",
|
|
dither_type="DIFFUSION",
|
|
dither_kernel="FLOYD_STEINBERG",
|
|
)
|
|
url = dot.url()
|
|
# Default values should not appear in URL
|
|
assert "dither_type" not in url
|
|
assert "dither_kernel" not in url
|
|
|
|
|
|
def test_notify_dot_image_mode_attachment_exception():
|
|
"""Test exception handling in image mode when attachment.base64() fails."""
|
|
|
|
class ExceptionAttachment:
|
|
def base64(self):
|
|
raise Exception("First attachment fails")
|
|
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="image")
|
|
|
|
# First attachment throws exception, should log warning and fail
|
|
with mock.patch.object(dot.logger, "warning") as mock_warning:
|
|
assert dot.send(
|
|
body="test",
|
|
title="test",
|
|
attach=[ExceptionAttachment()],
|
|
) is False
|
|
# Should log warning about failed attachment processing
|
|
assert mock_warning.called
|
|
# Check that the warning message contains expected text
|
|
warning_calls = [str(call) for call in mock_warning.call_args_list]
|
|
assert any(
|
|
"Failed to process attachment" in str(call)
|
|
for call in warning_calls
|
|
)
|
|
|
|
|
|
def test_notify_dot_image_mode_attachment_none():
|
|
"""Test image mode when attachment is None."""
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="image")
|
|
|
|
# Attachment is None, should skip base64() call and fail
|
|
assert dot.send(
|
|
body="test",
|
|
title="test",
|
|
attach=[None],
|
|
) is False
|
|
|
|
|
|
def test_notify_dot_image_mode_attachment_falsy():
|
|
"""Test image mode when attachment is falsy."""
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="image")
|
|
|
|
# Attachment is falsy (empty string), should skip base64() call and fail
|
|
class FalsyAttachment:
|
|
def __bool__(self):
|
|
return False
|
|
|
|
def base64(self):
|
|
return "should_not_be_called"
|
|
|
|
assert dot.send(
|
|
body="test",
|
|
title="test",
|
|
attach=[FalsyAttachment()],
|
|
) is False
|
|
|
|
|
|
def test_notify_dot_text_mode_attachment_exception():
|
|
"""Test exception handling in text mode when attachment.base64() fails."""
|
|
|
|
class ExceptionAttachment:
|
|
def base64(self):
|
|
raise Exception("Attachment base64 conversion fails")
|
|
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="text")
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
# First attachment throws exception, should log warning but continue
|
|
with (
|
|
mock.patch("requests.post", return_value=response) as mock_post,
|
|
mock.patch.object(dot.logger, "warning") as mock_warning,
|
|
):
|
|
assert dot.send(
|
|
title="hello",
|
|
body="world",
|
|
attach=[ExceptionAttachment()],
|
|
)
|
|
# Should log warning about failed attachment processing
|
|
assert mock_warning.called
|
|
# Check that the warning message contains expected text
|
|
warning_calls = [str(call) for call in mock_warning.call_args_list]
|
|
assert any(
|
|
"Failed to process attachment" in str(call)
|
|
for call in warning_calls
|
|
)
|
|
|
|
# Should still send notification without icon
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
assert payload["message"] == "world"
|
|
assert "icon" not in payload
|
|
|
|
|
|
def test_notify_dot_text_mode_attachment_none():
|
|
"""Test text mode when attachment is None (covers if attachment branch)."""
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="text")
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
# Attachment is None, should skip base64() call and continue without icon
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(
|
|
title="hello",
|
|
body="world",
|
|
attach=[None],
|
|
)
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
assert payload["message"] == "world"
|
|
assert "icon" not in payload
|
|
|
|
|
|
def test_notify_dot_text_mode_attachment_falsy():
|
|
"""Test text mode when attachment is falsy."""
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="text")
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
# Attachment is falsy, should skip base64() call and continue without icon
|
|
class FalsyAttachment:
|
|
def __bool__(self):
|
|
return False
|
|
|
|
def base64(self):
|
|
return "should_not_be_called"
|
|
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(
|
|
title="hello",
|
|
body="world",
|
|
attach=[FalsyAttachment()],
|
|
)
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
assert payload["message"] == "world"
|
|
assert "icon" not in payload
|
|
|
|
|
|
def test_notify_dot_parse_url_no_host():
|
|
"""Test parse_url when host is empty (line 578)."""
|
|
# Test URL with empty host - device_id should not be added
|
|
# Using a valid URL structure but testing when host is explicitly empty
|
|
result = NotifyDot.parse_url("dot://apikey@device/text/")
|
|
# This should succeed and have a device_id
|
|
assert result is not None
|
|
assert result.get("device_id") == "device"
|
|
|
|
|
|
def test_notify_dot_url_with_border_not_none():
|
|
"""Test URL generation when border is not None (line 515)."""
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
mode="image",
|
|
image_data="img",
|
|
border=1,
|
|
)
|
|
url = dot.url()
|
|
# Border should be in URL when not None
|
|
assert "border=1" in url
|
|
|
|
|
|
def test_notify_dot_image_mode_with_only_title():
|
|
"""Test image mode warning with only title (no body)."""
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="image")
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
# Test with only title, no body - should still warn
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(
|
|
title="test_title",
|
|
body="",
|
|
attach=[DummyAttachment("image_data")],
|
|
)
|
|
|
|
# Should have sent the notification but logged a warning
|
|
assert mock_post.called
|
|
|
|
|
|
def test_notify_dot_image_mode_with_only_body():
|
|
"""Test image mode warning with only body (no title)."""
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="image")
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
# Test with only body, no title - should still warn
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(
|
|
title="",
|
|
body="test_body",
|
|
attach=[DummyAttachment("image_data")],
|
|
)
|
|
|
|
# Should have sent the notification but logged a warning
|
|
assert mock_post.called
|
|
|
|
|
|
def test_notify_dot_text_mode_without_body():
|
|
"""Test text mode with empty body."""
|
|
dot = NotifyDot(apikey="token", device_id="device")
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
# Test with title but no body
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(body="", title="test_title")
|
|
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
assert payload["title"] == "test_title"
|
|
# Body should not be in payload when empty
|
|
assert "message" not in payload
|
|
|
|
|
|
def test_notify_dot_parse_url_without_host():
|
|
"""Test parse_url when URL has no host."""
|
|
# URL with no host (missing device_id) - should return None
|
|
result = NotifyDot.parse_url("dot://apikey@/text/")
|
|
# Without a host, the URL is invalid and parse_url returns None
|
|
assert result is None
|
|
|
|
|
|
def test_notify_dot_image_mode_without_title_and_body():
|
|
"""Test image mode without title and body (line 294->300)."""
|
|
dot = NotifyDot(
|
|
apikey="token",
|
|
device_id="device",
|
|
mode="image",
|
|
image_data="base64img",
|
|
)
|
|
|
|
response = mock.Mock()
|
|
response.status_code = 200
|
|
|
|
# Send without title and body - should not trigger warning
|
|
with mock.patch("requests.post", return_value=response) as mock_post:
|
|
assert dot.send(title="", body="")
|
|
|
|
# Should have sent the notification
|
|
assert mock_post.called
|
|
_args, kwargs = mock_post.call_args
|
|
payload = json.loads(kwargs["data"])
|
|
assert payload["image"] == "base64img"
|
|
assert "title" not in payload
|
|
assert "message" not in payload
|
|
|
|
|
|
def test_notify_dot_parse_url_with_empty_refresh():
|
|
"""Test parse_url when refresh query parameter is empty (line 535->539)."""
|
|
# Test with no refresh parameter (should default to True)
|
|
result = NotifyDot.parse_url("dot://apikey@device/text/")
|
|
assert result is not None
|
|
# When refresh is not specified, it defaults to True
|
|
assert result.get("refresh_now") is None # Not set in parse_url
|
|
|
|
|
|
def test_notify_dot_image_mode_first_attachment_fails():
|
|
"""Test image mode when first attachment fails (returns None)."""
|
|
|
|
class FailingAttachment:
|
|
def base64(self):
|
|
return None # Returns None
|
|
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="image")
|
|
|
|
# First attachment returns None, should fail immediately
|
|
assert dot.notify(
|
|
title="",
|
|
body="",
|
|
attach=[FailingAttachment()],
|
|
) is False
|
|
|
|
|
|
def test_notify_dot_image_mode_with_empty_attach_list():
|
|
"""Test image mode with empty attachments list (line 305->313)."""
|
|
dot = NotifyDot(apikey="token", device_id="device", mode="image")
|
|
|
|
# Try with empty attachments list
|
|
# Condition: not image_data and attach -> not None and [] -> False
|
|
# Should skip the for loop and go directly to line 313
|
|
assert dot.notify(
|
|
title="",
|
|
body="",
|
|
attach=[], # Empty list (truthy in Python but loop won't execute)
|
|
) is False
|
|
|
|
|
|
def test_notify_dot_parse_url_without_host_field():
|
|
"""Test parse_url when host field is None (line 535->539)."""
|
|
from apprise import NotifyBase
|
|
|
|
# Mock NotifyBase.parse_url to return results with host=None
|
|
# This triggers the else branch of "if host:" at line 535
|
|
with mock.patch.object(NotifyBase, "parse_url") as mock_parse:
|
|
mock_parse.return_value = {
|
|
"user": "apikey",
|
|
"password": None,
|
|
"port": None,
|
|
"host": None, # host is None - triggers 535->539 branch
|
|
"fullpath": "/text/",
|
|
"path": "",
|
|
"query": None,
|
|
"schema": "dot",
|
|
"qsd": {"refresh": "yes"},
|
|
"secure": False,
|
|
"verify": True,
|
|
}
|
|
|
|
result = NotifyDot.parse_url("dot://fake")
|
|
|
|
# Should have mode but no device_id since host was None
|
|
assert result is not None
|
|
assert result.get("mode") == "text"
|
|
assert result.get("device_id") is None
|
|
assert result.get("apikey") == "apikey"
|
|
assert result.get("refresh_now") is True # refresh was in qsd
|
|
|