Files
apprise/tests/test_plugin_dot.py
2025-11-15 11:26:23 -05:00

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