apprise/test/helpers/rest.py
2024-08-22 21:44:14 -04:00

677 lines
24 KiB
Python

# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, 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 re
import os
import requests
from unittest import mock
from json import dumps
from random import choice
from string import ascii_uppercase as str_alpha
from string import digits as str_num
from apprise import NotifyBase
from apprise import PersistentStoreMode
from apprise import NotifyType
from apprise import Apprise
from apprise import AppriseAsset
from apprise import AppriseAttachment
from apprise.common import OverflowMode
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
class AppriseURLTester:
# Some exception handling we'll use
req_exceptions = (
requests.ConnectionError(
0, 'requests.ConnectionError() not handled'),
requests.RequestException(
0, 'requests.RequestException() not handled'),
requests.HTTPError(
0, 'requests.HTTPError() not handled'),
requests.ReadTimeout(
0, 'requests.ReadTimeout() not handled'),
requests.TooManyRedirects(
0, 'requests.TooManyRedirects() not handled'),
)
# Attachment Testing Directory
__test_var_dir = os.path.join(
os.path.dirname(os.path.dirname(__file__)), 'var')
# Our URLs we'll test against
__tests = []
# Define how many characters exist per line
row = 80
# Some variables we use to control the data we work with
body_len = 1024
title_len = 1024
def __init__(self, tests=None, *args, **kwargs):
"""
Our initialization
"""
# Create a large body and title with random data
self.body = ''.join(
choice(str_alpha + str_num + ' ') for _ in range(self.body_len))
self.body = '\r\n'.join(
[self.body[i: i + self.row]
for i in range(0, len(self.body), self.row)])
# Create our title using random data
self.title = ''.join(
choice(str_alpha + str_num) for _ in range(self.title_len))
if tests:
self.__tests = tests
def add(self, url, meta):
"""
Adds a test suite to our object
"""
self.__tests.append({
'url': url,
'meta': meta,
})
def run_all(self, tmpdir=None):
"""
Run all of our tests
"""
# iterate over our dictionary and test it out
for (url, meta) in self.__tests:
self.run(url, meta, tmpdir)
@mock.patch('requests.get')
@mock.patch('requests.post')
@mock.patch('requests.request')
def run(self, url, meta, tmpdir, mock_request, mock_post, mock_get):
"""
Run a specific test
"""
# Our expected instance
instance = meta.get('instance', None)
# Our expected server objects
_self = meta.get('self', None)
# Our expected privacy url
# Don't set this if don't need to check it's value
privacy_url = meta.get('privacy_url')
# Our regular expression
url_matches = meta.get('url_matches')
# Detect our storage path (used to set persistent storage
# mode
storage_path = \
tmpdir if tmpdir and isinstance(tmpdir, str) and \
os.path.isdir(tmpdir) else None
# Our storage mode to set
storage_mode = meta.get(
'storage_mode',
PersistentStoreMode.MEMORY
if not storage_path else PersistentStoreMode.AUTO)
# Debug Mode
pdb = meta.get('pdb', False)
# Whether or not we should include an image with our request; unless
# otherwise specified, we assume that images are to be included
include_image = meta.get('include_image', True)
if include_image:
# a default asset
asset = AppriseAsset(
storage_mode=storage_mode,
storage_path=storage_path,
)
else:
# Disable images
asset = AppriseAsset(
image_path_mask=False, image_url_mask=False,
storage_mode=storage_mode,
storage_path=storage_path,
)
asset.image_url_logo = None
# Mock our request object
robj = mock.Mock()
robj.content = u''
mock_get.return_value = robj
mock_post.return_value = robj
mock_request.return_value = robj
if pdb:
# Makes it easier to debug with this peice of code
# just add `pdb': True to the call that is failing
import pdb
pdb.set_trace()
try:
# We can now instantiate our object:
obj = Apprise.instantiate(
url, asset=asset, suppress_exceptions=False)
except Exception as e:
# Handle our exception
if instance is None:
print('%s %s' % (url, str(e)))
raise e
if not isinstance(e, instance):
print('%s %s' % (url, str(e)))
raise e
# We're okay if we get here
return
if obj is None:
if instance is not None:
# We're done (assuming this is what we were
# expecting)
print("{} didn't instantiate itself "
"(we expected it to be a {})".format(
url, instance))
assert False
# We're done because we got the results we expected
return
if instance is None:
# Expected None but didn't get it
print('%s instantiated %s (but expected None)' % (
url, type(obj)))
assert False
if not isinstance(obj, instance):
print('%s instantiated %s (but expected %s)' % (
url, type(instance), type(obj)))
assert False
if isinstance(obj, NotifyBase):
# Ensure we are not performing any type of thorttling
obj.request_rate_per_sec = 0
# We loaded okay; now lets make sure we can reverse
# this url
assert isinstance(obj.url(), str) is True
# Test that we support a url identifier
url_id = obj.url_id()
# It can be either disabled or a string; nothing else
assert isinstance(url_id, str) or \
(url_id is None and obj.url_identifier is False)
# Verify we can acquire a target count as an integer
assert isinstance(len(obj), int)
# Test url() with privacy=True
assert isinstance(
obj.url(privacy=True), str) is True
# Some Simple Invalid Instance Testing
assert instance.parse_url(None) is None
assert instance.parse_url(object) is None
assert instance.parse_url(42) is None
if privacy_url:
# Assess that our privacy url is as expected
if not obj.url(privacy=True).startswith(privacy_url):
print('Provided %s' % url)
raise AssertionError(
"Privacy URL: '{}' != expected '{}'".format(
obj.url(privacy=True)[:len(privacy_url)],
privacy_url))
if url_matches:
# Assess that our URL matches a set regex
assert re.search(url_matches, obj.url())
# Instantiate the exact same object again using the URL
# from the one that was already created properly
obj_cmp = Apprise.instantiate(obj.url())
# Our new object should produce the same url identifier
if obj.url_identifier != obj_cmp.url_identifier:
print('Provided %s' % url)
raise AssertionError(
"URL Identifier: '{}' != expected '{}'".format(
obj_cmp.url_identifier, obj.url_identifier))
# Back our check up
if obj.url_id() != obj_cmp.url_id():
print('Provided %s' % url)
raise AssertionError(
"URL ID(): '{}' != expected '{}'".format(
obj_cmp.url_id(), obj.url_id()))
# Our object should be the same instance as what we had
# originally expected above.
if not isinstance(obj_cmp, NotifyBase):
# Assert messages are hard to trace back with the
# way these tests work. Just printing before
# throwing our assertion failure makes things
# easier to debug later on
print('TEST FAIL: {} regenerated as {}'.format(
url, obj.url()))
assert False
# Verify there is no change from the old and the new
if len(obj) != len(obj_cmp):
print('%d targets found in %s' % (
len(obj), obj.url(privacy=True)))
print('But %d targets found in %s' % (
len(obj_cmp), obj_cmp.url(privacy=True)))
raise AssertionError("Target miscount %d != %d")
# Tidy our object
del obj_cmp
del instance
if _self:
# Iterate over our expected entries inside of our
# object
for key, val in self.items():
# Test that our object has the desired key
assert hasattr(key, obj) is True
assert getattr(key, obj) == val
try:
self.__notify(url, obj, meta, asset)
except AssertionError:
# Don't mess with these entries
print('%s AssertionError' % url)
raise
# Tidy our object and allow any possible defined destructors to
# be executed.
del obj
@mock.patch('requests.get')
@mock.patch('requests.post')
@mock.patch('requests.head')
@mock.patch('requests.put')
@mock.patch('requests.delete')
@mock.patch('requests.patch')
@mock.patch('requests.request')
def __notify(
self,
url,
obj,
meta,
asset,
mock_request,
mock_patch,
mock_del,
mock_put,
mock_head,
mock_post,
mock_get
):
"""
Perform notification testing against object specified
"""
#
# Prepare our options
#
# Allow notification type override, otherwise default to INFO
notify_type = meta.get('notify_type', NotifyType.INFO)
# Whether or not we're testing exceptions or not
test_requests_exceptions = meta.get('test_requests_exceptions', False)
# Our expected Query response (True, False, or exception type)
response = meta.get('response', True)
# Our expected Notify response (True or False)
notify_response = meta.get('notify_response', response)
# Our expected Notify Attachment response (True or False)
attach_response = meta.get('attach_response', notify_response)
# Test attachments
# Don't set this if don't need to check it's value
check_attachments = meta.get('check_attachments', True)
# Allow us to force the server response code to be something other then
# the defaults
requests_response_code = meta.get(
'requests_response_code',
requests.codes.ok if response else requests.codes.not_found,
)
# Allow us to force the server response text to be something other then
# the defaults
requests_response_text = meta.get('requests_response_text')
requests_response_content = None
if isinstance(requests_response_text, str):
requests_response_content = requests_response_text.encode('utf-8')
elif isinstance(requests_response_text, bytes):
requests_response_content = requests_response_text
requests_response_text = requests_response_text.decode('utf-8')
elif not isinstance(requests_response_text, str):
# Convert to string
requests_response_text = dumps(requests_response_text)
requests_response_content = requests_response_text.encode('utf-8')
else:
requests_response_content = u''
requests_response_text = ''
# A request
robj = mock.Mock()
robj.content = u''
robj.text = ''
mock_get.return_value = robj
mock_post.return_value = robj
mock_head.return_value = robj
mock_patch.return_value = robj
mock_del.return_value = robj
mock_put.return_value = robj
mock_request.return_value = robj
if test_requests_exceptions is False:
# Handle our default response
mock_put.return_value.status_code = requests_response_code
mock_head.return_value.status_code = requests_response_code
mock_del.return_value.status_code = requests_response_code
mock_post.return_value.status_code = requests_response_code
mock_get.return_value.status_code = requests_response_code
mock_patch.return_value.status_code = requests_response_code
mock_request.return_value.status_code = requests_response_code
# Handle our default text response
mock_get.return_value.content = requests_response_content
mock_post.return_value.content = requests_response_content
mock_del.return_value.content = requests_response_content
mock_put.return_value.content = requests_response_content
mock_head.return_value.content = requests_response_content
mock_patch.return_value.content = requests_response_content
mock_request.return_value.content = requests_response_content
mock_get.return_value.text = requests_response_text
mock_post.return_value.text = requests_response_text
mock_put.return_value.text = requests_response_text
mock_del.return_value.text = requests_response_text
mock_head.return_value.text = requests_response_text
mock_patch.return_value.text = requests_response_text
mock_request.return_value.text = requests_response_text
# Ensure there is no side effect set
mock_post.side_effect = None
mock_del.side_effect = None
mock_put.side_effect = None
mock_head.side_effect = None
mock_get.side_effect = None
mock_patch.side_effect = None
mock_request.side_effect = None
else:
# Handle exception testing; first we turn the boolean flag
# into a list of exceptions
test_requests_exceptions = self.req_exceptions
try:
if test_requests_exceptions is False:
# Verify we can acquire a target count as an integer
targets = len(obj)
# check that we're as expected
_resp = obj.notify(
body=self.body, title=self.title,
notify_type=notify_type)
if _resp != notify_response:
print('%s notify() returned %s (but expected %s)' % (
url, _resp, notify_response))
assert False
if notify_response:
# If we successfully got a response, there must have been
# at least 1 target present
assert targets > 0
# check that this doesn't change using different overflow
# methods
assert obj.notify(
body=self.body, title=self.title,
notify_type=notify_type,
overflow=OverflowMode.UPSTREAM) == notify_response
assert obj.notify(
body=self.body, title=self.title,
notify_type=notify_type,
overflow=OverflowMode.TRUNCATE) == notify_response
assert obj.notify(
body=self.body, title=self.title,
notify_type=notify_type,
overflow=OverflowMode.SPLIT) == notify_response
#
# Handle varations of the Asset Object missing fields
#
# First make a backup
app_id = asset.app_id
app_desc = asset.app_desc
# now clear records
asset.app_id = None
asset.app_desc = None
# Notify should still work
assert obj.notify(
body=self.body, title=self.title,
notify_type=notify_type) == notify_response
# App ID only
asset.app_id = app_id
asset.app_desc = None
# Notify should still work
assert obj.notify(
body=self.body, title=self.title,
notify_type=notify_type) == notify_response
# App Desc only
asset.app_id = None
asset.app_desc = app_desc
# Notify should still work
assert obj.notify(
body=self.body, title=self.title,
notify_type=notify_type) == notify_response
# Restore
asset.app_id = app_id
asset.app_desc = app_desc
if check_attachments:
# Test single attachment support; even if the service
# doesn't support attachments, it should still
# gracefully ignore the data
attach = os.path.join(
self.__test_var_dir, 'apprise-test.gif')
assert obj.notify(
body=self.body, title=self.title,
notify_type=notify_type,
attach=attach) == attach_response
# Same results should apply to a list of attachments
attach = AppriseAttachment((
os.path.join(self.__test_var_dir, 'apprise-test.gif'),
os.path.join(self.__test_var_dir, 'apprise-test.png'),
os.path.join(self.__test_var_dir, 'apprise-test.jpeg'),
))
assert obj.notify(
body=self.body, title=self.title,
notify_type=notify_type,
attach=attach) == attach_response
if obj.attachment_support:
#
# Services that support attachments should support
# sending a attachment (or more) without a body or
# title specified:
#
assert obj.notify(
body=None, title=None,
notify_type=notify_type,
attach=attach) == attach_response
# Turn off attachment support on the notifications
# that support it so we can test that any logic we
# have ot test against this flag is ran
obj.attachment_support = False
#
# Notifications should still transmit as normal if
# Attachment support is flipped off
#
assert obj.notify(
body=self.body, title=self.title,
notify_type=notify_type,
attach=attach) == notify_response
#
# We should not be able to send a message without a
# body or title in this circumstance
#
assert obj.notify(
body=None, title=None,
notify_type=notify_type,
attach=attach) is False
# Toggle Back
obj.attachment_support = True
else: # No Attachment support
#
# We should not be able to send a message without a
# body or title in this circumstance
#
assert obj.notify(
body=None, title=None,
notify_type=notify_type,
attach=attach) is False
else:
for _exception in self.req_exceptions:
mock_post.side_effect = _exception
mock_head.side_effect = _exception
mock_del.side_effect = _exception
mock_put.side_effect = _exception
mock_get.side_effect = _exception
mock_patch.side_effect = _exception
mock_request.side_effect = _exception
try:
assert obj.notify(
body=self.body, title=self.title,
notify_type=notify_type) is False
except AssertionError:
# Don't mess with these entries
raise
except Exception:
# We can't handle this exception type
raise
except AssertionError:
# Don't mess with these entries
raise
except Exception as e:
# Check that we were expecting this exception to happen
try:
if not isinstance(e, response):
raise e
except TypeError:
print('%s Unhandled response %s' % (url, type(e)))
raise e
#
# Do the test again but without a title defined
#
try:
if test_requests_exceptions is False:
# check that we're as expected
assert obj.notify(body='body', notify_type=notify_type) \
== notify_response
else:
for _exception in self.req_exceptions:
mock_post.side_effect = _exception
mock_del.side_effect = _exception
mock_put.side_effect = _exception
mock_head.side_effect = _exception
mock_get.side_effect = _exception
mock_patch.side_effect = _exception
mock_request.side_effect = _exception
try:
assert obj.notify(
body=self.body,
notify_type=notify_type) is False
except AssertionError:
# Don't mess with these entries
raise
except Exception:
# We can't handle this exception type
raise
except AssertionError:
# Don't mess with these entries
raise
except Exception as e:
# Check that we were expecting this exception to happen
if not isinstance(e, response):
raise e
return True