# 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. # 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