Turn large ntfy messages into a attachments (#1136)

This commit is contained in:
Chris Caron 2024-06-01 21:43:38 -04:00 committed by GitHub
parent e289279896
commit de3acafef9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 2864 additions and 42 deletions

View File

@ -142,13 +142,8 @@ class AppriseAttachment:
# prepare default asset
asset = self.asset
if isinstance(attachments, AttachBase):
# Go ahead and just add our attachments into our list
self.attachments.append(attachments)
return True
elif isinstance(attachments, str):
# Save our path
if isinstance(attachments, (AttachBase, str)):
# store our instance
attachments = (attachments, )
elif not isinstance(attachments, (tuple, set, list)):

View File

@ -148,6 +148,9 @@ class AttachBase(URLBase):
# Absolute path to attachment
self.download_path = None
# Track open file pointers
self.__pointers = set()
# Set our cache flag; it can be True, False, None, or a (positive)
# integer... nothing else
if cache is not None:
@ -226,15 +229,14 @@ class AttachBase(URLBase):
Content is cached once determied to prevent overhead of future
calls.
"""
if not self.exists():
# we could not obtain our attachment
return None
if self._mimetype:
# return our pre-calculated cached content
return self._mimetype
if not self.exists():
# we could not obtain our attachment
return None
if not self.detected_mimetype:
# guess_type() returns: (type, encoding) and sets type to None
# if it can't otherwise determine it.
@ -258,6 +260,9 @@ class AttachBase(URLBase):
Simply returns true if the object has downloaded and stored the
attachment AND the attachment has not expired.
"""
if self.location == ContentLocation.INACCESSIBLE:
# our content is inaccessible
return False
cache = self.template_args['cache']['default'] \
if self.cache is None else self.cache
@ -295,6 +300,11 @@ class AttachBase(URLBase):
- download_path: Must contain a absolute path to content
- detected_mimetype: Should identify mimetype of content
"""
# Remove all open pointers
while self.__pointers:
self.__pointers.pop().close()
self.detected_name = None
self.download_path = None
self.detected_mimetype = None
@ -314,6 +324,26 @@ class AttachBase(URLBase):
raise NotImplementedError(
"download() is implimented by the child class.")
def open(self, mode='rb'):
"""
return our file pointer and track it (we'll auto close later
"""
pointer = open(self.path, mode=mode)
self.__pointers.add(pointer)
return pointer
def __enter__(self):
"""
support with keyword
"""
return self.open()
def __exit__(self, value_type, value, traceback):
"""
stub to do nothing; but support exit of with statement gracefully
"""
return
@staticmethod
def parse_url(url, verify_host=True, mimetype_db=None, sanitize=True):
"""Parses the URL and returns it broken apart into a dictionary.
@ -376,3 +406,9 @@ class AttachBase(URLBase):
True is returned if our content was downloaded correctly.
"""
return True if self.path else False
def __del__(self):
"""
Perform any house cleaning
"""
self.invalidate()

View File

@ -78,7 +78,8 @@ class AttachFile(AttachBase):
return 'file://{path}{params}'.format(
path=self.quote(self.dirty_path),
params='?{}'.format(self.urlencode(params)) if params else '',
params='?{}'.format(self.urlencode(params, safe='/'))
if params else '',
)
def download(self, **kwargs):

View File

@ -352,7 +352,7 @@ class AttachHTTP(AttachBase):
port='' if self.port is None or self.port == default_port
else ':{}'.format(self.port),
fullpath=self.quote(self.fullpath, safe='/'),
params=self.urlencode(params),
params=self.urlencode(params, safe='/'),
)
@staticmethod

View File

@ -0,0 +1,212 @@
# -*- 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 io
from .base import AttachBase
from ..common import ContentLocation
from ..locale import gettext_lazy as _
import uuid
class AttachMemory(AttachBase):
"""
A wrapper for Memory based attachment sources
"""
# The default descriptive name associated with the service
service_name = _('Memory')
# The default protocol
protocol = 'memory'
# Content is local to the same location as the apprise instance
# being called (server-side)
location = ContentLocation.LOCAL
def __init__(self, content=None, name=None, mimetype=None,
encoding='utf-8', **kwargs):
"""
Initialize Memory Based Attachment Object
"""
# Create our BytesIO object
self._data = io.BytesIO()
if content is None:
# Empty; do nothing
pass
elif isinstance(content, str):
content = content.encode(encoding)
if mimetype is None:
mimetype = 'text/plain'
if not name:
# Generate a unique filename
name = str(uuid.uuid4()) + '.txt'
elif not isinstance(content, bytes):
raise TypeError(
'Provided content for memory attachment is invalid')
# Store our content
if content:
self._data.write(content)
if mimetype is None:
# Default mimetype
mimetype = 'application/octet-stream'
if not name:
# Generate a unique filename
name = str(uuid.uuid4()) + '.dat'
# Initialize our base object
super().__init__(name=name, mimetype=mimetype, **kwargs)
return
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Define any URL parameters
params = {
'mime': self._mimetype,
}
return 'memory://{name}?{params}'.format(
name=self.quote(self._name),
params=self.urlencode(params, safe='/')
)
def open(self, *args, **kwargs):
"""
return our memory object
"""
# Return our object
self._data.seek(0, 0)
return self._data
def __enter__(self):
"""
support with clause
"""
# Return our object
self._data.seek(0, 0)
return self._data
def download(self, **kwargs):
"""
Handle memory download() call
"""
if self.location == ContentLocation.INACCESSIBLE:
# our content is inaccessible
return False
if self.max_file_size > 0 and len(self) > self.max_file_size:
# The content to attach is to large
self.logger.error(
'Content exceeds allowable maximum memory size '
'({}KB): {}'.format(
int(self.max_file_size / 1024), self.url(privacy=True)))
# Return False (signifying a failure)
return False
return True
def invalidate(self):
"""
Removes data
"""
self._data.truncate(0)
return
def exists(self):
"""
over-ride exists() call
"""
size = len(self)
return True if self.location != ContentLocation.INACCESSIBLE \
and size > 0 and (
self.max_file_size <= 0 or
(self.max_file_size > 0 and size <= self.max_file_size)) \
else False
@staticmethod
def parse_url(url):
"""
Parses the URL so that we can handle all different file paths
and return it as our path object
"""
results = AttachBase.parse_url(url, verify_host=False)
if not results:
# We're done early; it's not a good URL
return results
if 'name' not in results:
# Allow fall-back to be from URL
match = re.match(r'memory://(?P<path>[^?]+)(\?.*)?', url, re.I)
if match:
# Store our filename only (ignore any defined paths)
results['name'] = \
os.path.basename(AttachMemory.unquote(match.group('path')))
return results
@property
def path(self):
"""
return the filename
"""
if not self.exists():
# we could not obtain our path
return None
return self._name
def __len__(self):
"""
Returns the size of he memory attachment
"""
return self._data.getbuffer().nbytes
def __bool__(self):
"""
Allows the Apprise object to be wrapped in an based 'if statement'.
True is returned if our content was downloaded correctly.
"""
return self.exists()

View File

@ -53,6 +53,7 @@ from ..utils import is_ipaddr
from ..utils import validate_regex
from ..url import PrivacyMode
from ..attachment.base import AttachBase
from ..attachment.memory import AttachMemory
class NtfyMode:
@ -172,6 +173,20 @@ class NotifyNtfy(NotifyBase):
# Support attachments
attachment_support = True
# Maximum title length
title_maxlen = 200
# Maximum body length
body_maxlen = 7800
# Message size calculates title and body together
overflow_amalgamate_title = True
# Defines the number of bytes our JSON object can not exceed in size or we
# know the upstream server will reject it. We convert these into
# attachments
ntfy_json_upstream_size_limit = 8000
# Allows the user to specify the NotifyImageSize object
image_size = NotifyImageSize.XY_256
@ -504,7 +519,7 @@ class NotifyNtfy(NotifyBase):
# Prepare our Header
virt_payload['filename'] = attach.name
with open(attach.path, 'rb') as fp:
with attach as fp:
data = fp.read()
if image_url:
@ -538,18 +553,39 @@ class NotifyNtfy(NotifyBase):
self.logger.debug('ntfy POST URL: %s (cert_verify=%r)' % (
notify_url, self.verify_certificate,
))
self.logger.debug('ntfy Payload: %s' % str(virt_payload))
self.logger.debug('ntfy Headers: %s' % str(headers))
# Always call throttle before any remote server i/o is made
self.throttle()
# Default response type
response = None
if not attach:
data = dumps(data)
if len(data) > self.ntfy_json_upstream_size_limit:
# Convert to an attachment
if self.notify_format == NotifyFormat.MARKDOWN:
mimetype = 'text/markdown'
elif self.notify_format == NotifyFormat.TEXT:
mimetype = 'text/plain'
else: # self.notify_format == NotifyFormat.HTML:
mimetype = 'text/html'
attach = AttachMemory(
mimetype=mimetype,
content='{title}{body}'.format(
title=title + '\n' if title else '', body=body))
# Recursively send the message body as an attachment instead
return self._send(
topic=topic, body='', title='', attach=attach,
image_url=image_url, **kwargs)
self.logger.debug('ntfy Payload: %s' % str(virt_payload))
self.logger.debug('ntfy Headers: %s' % str(headers))
# Always call throttle before any remote server i/o is made
self.throttle()
try:
r = requests.post(
notify_url,

View File

@ -1,10 +1,19 @@
diff -Naur apprise-1.6.0/test/conftest.py apprise-1.6.0-patched/test/conftest.py
--- apprise-1.6.0/test/conftest.py 2023-12-27 11:20:40.000000000 -0500
+++ apprise-1.6.0-patched/test/conftest.py 2023-12-27 13:43:22.583100037 -0500
@@ -45,8 +45,8 @@
diff -Naur apprise-1.8.0/test/conftest.py apprise-1.8.0-patched/test/conftest.py
--- apprise-1.8.0/test/conftest.py 2024-06-01 21:28:32.102470720 -0400
+++ apprise-1.8.0-patched/test/conftest.py 2024-06-01 21:29:32.363754277 -0400
@@ -46,7 +46,7 @@
A_MGR = AttachmentManager()
-@pytest.fixture(scope="function", autouse=True)
+@pytest.fixture(autouse=True)
def mimetypes_always_available():
"""
A pytest session fixture which ensures mimetypes is set correctly
@@ -56,8 +56,8 @@
mimetypes.init(files=files)
-@pytest.fixture(scope="function", autouse=True)
-def no_throttling_everywhere(session_mocker):
+@pytest.fixture(autouse=True)
@ -12,7 +21,7 @@ diff -Naur apprise-1.6.0/test/conftest.py apprise-1.6.0-patched/test/conftest.py
"""
A pytest session fixture which disables throttling on all notifiers.
It is automatically enabled.
@@ -57,4 +57,4 @@
@@ -68,4 +68,4 @@
A_MGR.unload_modules()
for plugin in N_MGR.plugins():

View File

@ -30,6 +30,7 @@ import sys
import os
import pytest
import mimetypes
from apprise import NotificationManager
from apprise import ConfigurationManager
@ -45,6 +46,16 @@ C_MGR = ConfigurationManager()
A_MGR = AttachmentManager()
@pytest.fixture(scope="function", autouse=True)
def mimetypes_always_available():
"""
A pytest session fixture which ensures mimetypes is set correctly
pointing to our temporary mime.types file
"""
files = (os.path.join(os.path.dirname(__file__), 'var', 'mime.types'), )
mimetypes.init(files=files)
@pytest.fixture(scope="function", autouse=True)
def no_throttling_everywhere(session_mocker):
"""

View File

@ -284,9 +284,6 @@ def test_apprise_attachment_truncate(mock_get):
# Add ourselves an object set to truncate
ap_obj.add('json://localhost/?method=GET&overflow=truncate')
# Add ourselves a second object without truncate
ap_obj.add('json://localhost/?method=GET&overflow=upstream')
# Create ourselves an attachment object
aa = AppriseAttachment()
@ -304,15 +301,36 @@ def test_apprise_attachment_truncate(mock_get):
assert mock_get.call_count == 0
assert ap_obj.notify(body='body', title='title', attach=aa)
assert mock_get.call_count == 2
assert mock_get.call_count == 1
# Our first item was truncated, so only 1 attachment
details = mock_get.call_args_list[0]
dataset = json.loads(details[1]['data'])
assert len(dataset['attachments']) == 1
# Our second item was not truncated, so all attachments
details = mock_get.call_args_list[1]
# Reset our object
mock_get.reset_mock()
# our Apprise Object
ap_obj = Apprise()
# Add ourselves an object set to upstream
ap_obj.add('json://localhost/?method=GET&overflow=upstream')
# Create ourselves an attachment object
aa = AppriseAttachment()
# Add 2 attachments
assert aa.add(join(TEST_VAR_DIR, 'apprise-test.gif'))
assert aa.add(join(TEST_VAR_DIR, 'apprise-test.png'))
assert mock_get.call_count == 0
assert ap_obj.notify(body='body', title='title', attach=aa)
assert mock_get.call_count == 1
# Our item was not truncated, so all attachments
details = mock_get.call_args_list[0]
dataset = json.loads(details[1]['data'])
assert len(dataset['attachments']) == 2

View File

@ -73,15 +73,6 @@ def test_attach_base():
with pytest.raises(NotImplementedError):
obj.download()
with pytest.raises(NotImplementedError):
obj.name
with pytest.raises(NotImplementedError):
obj.path
with pytest.raises(NotImplementedError):
obj.mimetype
# Unsupported URLs are not parsed
assert AttachBase.parse_url(url='invalid://') is None

View File

@ -204,8 +204,7 @@ def test_attach_file():
assert response.path == path
assert response.name == 'test.jpeg'
assert response.mimetype == 'image/jpeg'
# We will match on mime type now (%2F = /)
assert re.search(r'[?&]mime=image%2Fjpeg', response.url(), re.I)
assert re.search(r'[?&]mime=image/jpeg', response.url(), re.I)
assert re.search(r'[?&]name=test\.jpeg', response.url(), re.I)
# Test hosted configuration and that we can't add a valid file

View File

@ -229,7 +229,7 @@ def test_attach_http(mock_get, mock_post):
attachment = AttachHTTP(**results)
assert isinstance(attachment.url(), str) is True
# both mime and name over-ridden
assert re.search(r'[?&]mime=image%2Fjpeg', attachment.url())
assert re.search(r'[?&]mime=image/jpeg', attachment.url())
assert re.search(r'[?&]name=usethis.jpg', attachment.url())
# No Content-Disposition; so we use filename from path
assert attachment.name == 'usethis.jpg'

205
test/test_attach_memory.py Normal file
View File

@ -0,0 +1,205 @@
# -*- 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 urllib
import pytest
from apprise.attachment.base import AttachBase
from apprise.attachment.memory import AttachMemory
from apprise import AppriseAttachment
from apprise.common import ContentLocation
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
def test_attach_memory_parse_url():
"""
API: AttachMemory().parse_url()
"""
# Bad Entry
assert AttachMemory.parse_url(object) is None
# Our filename is detected automatically
assert AttachMemory.parse_url('memory://')
# pass our content in as a string
mem = AttachMemory(content='string')
# it loads a string type by default
mem.mimetype == 'text/plain'
# Our filename is automatically generated (with .txt)
assert re.match(r'^[a-z0-9-]+\.txt$', mem.name, re.I)
# open our file
with mem as fp:
assert fp.getbuffer().nbytes == len(mem)
# pass our content in as a string
mem = AttachMemory(
content='<html/>', name='test.html', mimetype='text/html')
# it loads a string type by default
mem.mimetype == 'text/html'
mem.name == 'test.html'
# Stub function
assert mem.download()
with pytest.raises(TypeError):
# garbage in, garbage out
AttachMemory(content=3)
# pointer to our data
pointer = mem.open()
assert pointer.read() == b'<html/>'
# pass our content in as a string
mem = AttachMemory(content=b'binary-data', name='raw.dat')
# it loads a string type by default
assert mem.mimetype == 'application/octet-stream'
mem.name == 'raw'
# pass our content in as a string
mem = AttachMemory(content=b'binary-data')
# it loads a string type by default
assert mem.mimetype == 'application/octet-stream'
# Our filename is automatically generated (with .dat)
assert re.match(r'^[a-z0-9-]+\.dat$', mem.name, re.I)
def test_attach_memory():
"""
API: AttachMemory()
"""
# A url we can test with
fname = 'testfile'
url = 'memory:///ignored/path/{fname}'.format(fname=fname)
# Simple gif test
response = AppriseAttachment.instantiate(url)
assert isinstance(response, AttachMemory)
# There is no path yet as we haven't written anything to our memory object
# yet
assert response.path is None
assert bool(response) is False
with response as memobj:
memobj.write(b'content')
# Memory object defaults
assert response.name == fname
assert response.path == response.name
assert response.mimetype == 'application/octet-stream'
assert bool(response) is True
#
fname_in_url = urllib.parse.quote(response.name)
assert response.url().startswith('memory://{}'.format(fname_in_url))
# Mime is always part of url
assert re.search(r'[?&]mime=', response.url()) is not None
# Test case where location is simply set to INACCESSIBLE
# Below is a bad example, but it proves the section of code properly works.
# Ideally a server admin may wish to just disable all File based
# attachments entirely. In this case, they simply just need to change the
# global singleton at the start of their program like:
#
# import apprise
# apprise.attachment.AttachMemory.location = \
# apprise.ContentLocation.INACCESSIBLE
#
response = AppriseAttachment.instantiate(url)
assert isinstance(response, AttachMemory)
with response as memobj:
memobj.write(b'content')
response.location = ContentLocation.INACCESSIBLE
assert response.path is None
# Downloads just don't work period
assert response.download() is False
# File handling (even if image is set to maxium allowable)
response = AppriseAttachment.instantiate(url)
assert isinstance(response, AttachMemory)
with response as memobj:
memobj.write(b'content')
# Memory handling when size is to large
response = AppriseAttachment.instantiate(url)
assert isinstance(response, AttachMemory)
with response as memobj:
memobj.write(b'content')
# Test case where we exceed our defined max_file_size in memory
prev_value = AttachBase.max_file_size
AttachBase.max_file_size = len(response) - 1
# We can't work in this case
assert response.path is None
assert response.download() is False
# Restore our file_size
AttachBase.max_file_size = prev_value
response = AppriseAttachment.instantiate(
'memory://apprise-file.gif?mime=image/gif')
assert isinstance(response, AttachMemory)
with response as memobj:
memobj.write(b'content')
assert response.name == 'apprise-file.gif'
assert response.path == response.name
assert response.mimetype == 'image/gif'
# No mime-type and/or filename over-ride was specified, so therefore it
# won't show up in the generated URL
assert re.search(r'[?&]mime=', response.url()) is not None
assert 'image/gif' in response.url()
# Force a mime-type and new name
response = AppriseAttachment.instantiate(
'memory://{}?mime={}&name={}'.format(
'ignored.gif', 'image/jpeg', 'test.jpeg'))
assert isinstance(response, AttachMemory)
with response as memobj:
memobj.write(b'content')
assert response.name == 'test.jpeg'
assert response.path == response.name
assert response.mimetype == 'image/jpeg'
# We will match on mime type now (%2F = /)
assert re.search(r'[?&]mime=image/jpeg', response.url(), re.I)
assert response.url().startswith('memory://test.jpeg')
# Test hosted configuration and that we can't add a valid memory file
aa = AppriseAttachment(location=ContentLocation.HOSTED)
assert aa.add(response) is False

View File

@ -26,6 +26,7 @@
# 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 json
from unittest import mock
@ -571,3 +572,47 @@ def test_plugin_ntfy_config_files(mock_post, mock_get):
assert len([x for x in aobj.find(tag='ntfy_invalid')]) == 1
assert next(aobj.find(tag='ntfy_invalid')).priority == \
NtfyPriority.NORMAL
@mock.patch('requests.post')
def test_plugin_ntfy_message_to_attach(mock_post):
"""
NotifyNtfy() large messages converted into attachments
"""
# Prepare Mock return object
response = mock.Mock()
response.content = GOOD_RESPONSE_TEXT
response.status_code = requests.codes.ok
mock_post.return_value = response
# Create a very, very big message
title = 'My Title'
body = 'b' * NotifyNtfy.ntfy_json_upstream_size_limit
for fmt in apprise.NOTIFY_FORMATS:
# Prepare our object
obj = apprise.Apprise.instantiate(
'ntfy://user:pass@localhost:8080/topic?format={}'.format(fmt))
# Our content will actually transfer as an attachment
assert obj.notify(title=title, body=body)
assert mock_post.call_count == 1
assert mock_post.call_args_list[0][0][0] == \
'http://localhost:8080/topic'
response = mock_post.call_args_list[0][1]
assert 'data' in response
assert response['data'].decode('utf-8').startswith(title)
assert response['data'].decode('utf-8').endswith(body)
assert 'params' in response
assert 'filename' in response['params']
# Our filename is automatically generated (with .txt)
assert re.match(
r'^[a-z0-9-]+\.txt$', response['params']['filename'], re.I)
# Reset our mock object
mock_post.reset_mock()

2264
test/var/mime.types Normal file

File diff suppressed because it is too large Load Diff