From 13de0ff95d939eea3b7dccb5994d182438d086c7 Mon Sep 17 00:00:00 2001 From: Zachary Orr Date: Thu, 2 May 2019 19:09:07 -0400 Subject: [PATCH 1/8] Add messaging send_all and send_multicast functions --- firebase_admin/_messaging_utils.py | 29 ++- firebase_admin/messaging.py | 213 ++++++++++++++-- requirements.txt | 1 + setup.py | 1 + tests/test_messaging.py | 392 +++++++++++++++++++++++++++++ 5 files changed, 611 insertions(+), 25 deletions(-) diff --git a/firebase_admin/_messaging_utils.py b/firebase_admin/_messaging_utils.py index aba809f22..63623e33e 100644 --- a/firebase_admin/_messaging_utils.py +++ b/firebase_admin/_messaging_utils.py @@ -54,6 +54,33 @@ def __init__(self, data=None, notification=None, android=None, webpush=None, apn self.condition = condition +class MulticastMessage(object): + """A message that can be sent to multiple tokens via Firebase Cloud Messaging. + + Contains payload information as well as recipient information. In particular, the message must + contain exactly one of token, topic or condition fields. + + Args: + tokens: A list of registration token of the device to which the message should be sent. + data: A dictionary of data fields (optional). All keys and values in the dictionary must be + strings. + notification: An instance of ``messaging.Notification`` (optional). + android: An instance of ``messaging.AndroidConfig`` (optional). + webpush: An instance of ``messaging.WebpushConfig`` (optional). + apns: An instance of ``messaging.ApnsConfig`` (optional). + """ + def __init__(self, tokens, data=None, notification=None, android=None, webpush=None, apns=None): + _Validators.check_string_list('MulticastMessage.tokens', tokens) + if len(tokens) > 100: + raise ValueError('MulticastMessage.tokens must contain less than 100 tokens.') + self.tokens = tokens + self.data = data + self.notification = notification + self.android = android + self.webpush = webpush + self.apns = apns + + class Notification(object): """A notification that can be included in a message. @@ -150,7 +177,7 @@ class WebpushConfig(object): data: A dictionary of data fields (optional). All keys and values in the dictionary must be strings. When specified, overrides any data fields set via ``Message.data``. notification: A ``messaging.WebpushNotification`` to be included in the message (optional). - fcm_options: A ``messaging.WebpushFcmOptions`` instance to be included in the messsage + fcm_options: A ``messaging.WebpushFcmOptions`` instance to be included in the message (optional). .. _Webpush Specification: https://tools.ietf.org/html/rfc8030#section-5 diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index f7988320d..bec17bcf8 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -14,9 +14,14 @@ """Firebase Cloud Messaging module.""" +import json import requests import six +import googleapiclient +from googleapiclient import http +from googleapiclient import _auth + import firebase_admin from firebase_admin import _http_client from firebase_admin import _messaging_utils @@ -34,10 +39,13 @@ 'ApiCallError', 'Aps', 'ApsAlert', + 'BatchResponse', 'CriticalSound', 'ErrorInfo', 'Message', + 'MulticastMessage', 'Notification', + 'SendResponse', 'TopicManagementResponse', 'WebpushConfig', 'WebpushFcmOptions', @@ -45,6 +53,8 @@ 'WebpushNotificationAction', 'send', + 'send_all', + 'send_multicast', 'subscribe_to_topic', 'unsubscribe_from_topic', ] @@ -58,6 +68,7 @@ ApsAlert = _messaging_utils.ApsAlert CriticalSound = _messaging_utils.CriticalSound Message = _messaging_utils.Message +MulticastMessage = _messaging_utils.MulticastMessage Notification = _messaging_utils.Notification WebpushConfig = _messaging_utils.WebpushConfig WebpushFcmOptions = _messaging_utils.WebpushFcmOptions @@ -88,6 +99,56 @@ def send(message, dry_run=False, app=None): """ return _get_messaging_service(app).send(message, dry_run) +def send_all(messages, dry_run=False, app=None): + """Batch sends the given messages via Firebase Cloud Messaging (FCM). + + If the ``dry_run`` mode is enabled, the message will not be actually delivered to the + recipients. Instead FCM performs all the usual validations, and emulates the send operation. + + Args: + messages: A list of ``messaging.Message`` instances. + dry_run: A boolean indicating whether to run the operation in dry run mode (optional). + app: An App instance (optional). + + Returns: + BatchResponse: A ``messaging.BatchResponse`` instance. + + Raises: + ApiCallError: If an error occurs while sending the message to FCM service. + ValueError: If the input arguments are invalid. + """ + return _get_messaging_service(app).send_all(messages, dry_run) + +def send_multicast(multicast_message, dry_run=False, app=None): + """Sends the given mutlicast message to the mutlicast message tokens via Firebase Cloud Messaging (FCM). + + If the ``dry_run`` mode is enabled, the message will not be actually delivered to the + recipients. Instead FCM performs all the usual validations, and emulates the send operation. + + Args: + message: An instance of ``messaging.MulticastMessage``. + dry_run: A boolean indicating whether to run the operation in dry run mode (optional). + app: An App instance (optional). + + Returns: + BatchResponse: A ``messaging.BatchResponse`` instance. + + Raises: + ApiCallError: If an error occurs while sending the message to FCM service. + ValueError: If the input arguments are invalid. + """ + if not isinstance(multicast_message, MulticastMessage): + raise ValueError('Message must be an instance of messaging.MulticastMessage class.') + messages = map(lambda token: Message( + data=multicast_message.data, + notification=multicast_message.notification, + android=multicast_message.android, + webpush=multicast_message.webpush, + apns=multicast_message.apns, + token=token + ), multicast_message.tokens) + return _get_messaging_service(app).send_all(messages, dry_run) + def subscribe_to_topic(tokens, topic, app=None): """Subscribes a list of registration tokens to an FCM topic. @@ -192,10 +253,58 @@ def __init__(self, code, message, detail=None): self.detail = detail +class BatchResponse(object): + + def __init__(self, responses): + self._responses = responses + self._success_count = 0 + for response in responses: + if response.success: + self._success_count += 1 + + @property + def responses(self): + """A list of ``messaging.SendResponse`` objects (possibly empty).""" + return self._responses + + @property + def success_count(self): + return self._success_count + + @property + def failure_count(self): + return len(self.responses) - self.success_count + + +class SendResponse(object): + + def __init__(self, resp, exception): + self._exception = exception + self._message_id = None + if resp: + self._message_id = resp.get('name', None) + + @property + def message_id(self): + """A message ID string that uniquely identifies the sent the message.""" + return self._message_id + + @property + def success(self): + """A boolean indicating if the request was successful.""" + return self._message_id is not None and not self._exception + + @property + def exception(self): + """A ApiCallError if an error occurs while sending the message to FCM service.""" + return self._exception + + class _MessagingService(object): """Service class that implements Firebase Cloud Messaging (FCM) functionality.""" FCM_URL = 'https://fcm.googleapis.com/v1/projects/{0}/messages:send' + FCM_BATCH_URL = 'https://fcm.googleapis.com/batch' IID_URL = 'https://iid.googleapis.com' IID_HEADERS = {'access_token_auth': 'true'} JSON_ENCODER = _messaging_utils.MessageEncoder() @@ -234,9 +343,13 @@ def __init__(self, app): 'projectId option, or use service account credentials. Alternatively, set the ' 'GOOGLE_CLOUD_PROJECT environment variable.') self._fcm_url = _MessagingService.FCM_URL.format(project_id) + self._fcm_headers = { + 'X-GOOG-API-FORMAT-VERSION': '2', + 'X-FIREBASE-CLIENT': 'fire-admin-python/{0}'.format(firebase_admin.__version__), + } self._client = _http_client.JsonHttpClient(credential=app.credential.get_credential()) self._timeout = app.options.get('httpTimeout') - self._client_version = 'fire-admin-python/{0}'.format(firebase_admin.__version__) + self._transport = _auth.authorized_http(app.credential.get_credential()) @classmethod def encode_message(cls, message): @@ -245,16 +358,10 @@ def encode_message(cls, message): return cls.JSON_ENCODER.default(message) def send(self, message, dry_run=False): - data = {'message': _MessagingService.encode_message(message)} - if dry_run: - data['validate_only'] = True + data = self._message_data(message, dry_run) try: - headers = { - 'X-GOOG-API-FORMAT-VERSION': '2', - 'X-FIREBASE-CLIENT': self._client_version, - } resp = self._client.body( - 'post', url=self._fcm_url, headers=headers, json=data, timeout=self._timeout) + 'post', url=self._fcm_url, headers=self._fcm_headers, json=data, timeout=self._timeout) except requests.exceptions.RequestException as error: if error.response is not None: self._handle_fcm_error(error) @@ -264,6 +371,33 @@ def send(self, message, dry_run=False): else: return resp['name'] + def send_all(self, messages, dry_run=False): + if not isinstance(messages, list): + raise ValueError('Messages must be an list of messaging.Message instances.') + + responses = [] + + def batch_callback(request_id, response, error): + exception = None + if error: + exception = self._parse_batch_error(error) + send_response = SendResponse(response, exception) + responses.append(send_response) + + batch = http.BatchHttpRequest(batch_callback, _MessagingService.FCM_BATCH_URL) + for message in messages: + body = json.dumps(self._message_data(message, dry_run)) + req = http.HttpRequest( + http=self._transport, postproc=self._postproc, uri=self._fcm_url, method='POST', body=body, headers=self._fcm_headers) + batch.add(req) + + try: + batch.execute() + except googleapiclient.http.HttpError as error: + raise self._parse_batch_error(error) + else: + return BatchResponse(responses) + def make_topic_management_request(self, tokens, topic, operation): """Invokes the IID service for topic management functionality.""" if isinstance(tokens, six.string_types): @@ -299,6 +433,18 @@ def make_topic_management_request(self, tokens, topic, operation): else: return TopicManagementResponse(resp) + def _message_data(self, message, dry_run): + data = {'message': _MessagingService.encode_message(message)} + if dry_run: + data['validate_only'] = True + return data + + def _postproc(self, resp, body): + if resp.status == 200: + return json.loads(body) + else: + raise Exception('unexpected response') + def _handle_fcm_error(self, error): """Handles errors received from the FCM API.""" data = {} @@ -309,21 +455,7 @@ def _handle_fcm_error(self, error): except ValueError: pass - error_dict = data.get('error', {}) - server_code = None - for detail in error_dict.get('details', []): - if detail.get('@type') == 'type.googleapis.com/google.firebase.fcm.v1.FcmError': - server_code = detail.get('errorCode') - break - if not server_code: - server_code = error_dict.get('status') - code = _MessagingService.FCM_ERROR_CODES.get(server_code, _MessagingService.UNKNOWN_ERROR) - - msg = error_dict.get('message') - if not msg: - msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format( - error.response.status_code, error.response.content.decode()) - raise ApiCallError(code, msg, error) + raise _MessagingService._parse_fcm_error(data, error.response.content, error.response.status_code, error) def _handle_iid_error(self, error): """Handles errors received from the Instance ID API.""" @@ -342,3 +474,36 @@ def _handle_iid_error(self, error): msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format( error.response.status_code, error.response.content.decode()) raise ApiCallError(code, msg, error) + + def _parse_batch_error(self, error): + if error.content is None: + msg = 'Failed to call messaging API: {0}'.format(error) + return ApiCallError(self.INTERNAL_ERROR, msg, error) + + data = {} + try: + parsed_body = json.loads(error.content) + if isinstance(parsed_body, dict): + data = parsed_body + except ValueError: + pass + return _MessagingService._parse_fcm_error(data, error.content, error.resp.status, error) + + @classmethod + def _parse_fcm_error(cls, data, content, status_code, error): + """Parses an error response from the FCM API to a ApiCallError.""" + error_dict = data.get('error', {}) + server_code = None + for detail in error_dict.get('details', []): + if detail.get('@type') == 'type.googleapis.com/google.firebase.fcm.v1.FcmError': + server_code = detail.get('errorCode') + break + if not server_code: + server_code = error_dict.get('status') + code = _MessagingService.FCM_ERROR_CODES.get(server_code, _MessagingService.UNKNOWN_ERROR) + + msg = error_dict.get('message') + if not msg: + msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(status_code, content.decode()) + + return ApiCallError(code, msg, error) diff --git a/requirements.txt b/requirements.txt index 03bbe7271..7a8d855bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ tox >= 3.6.0 cachecontrol >= 0.12.4 google-api-core[grpc] >= 1.7.0, < 2.0.0dev; platform.python_implementation != 'PyPy' +google-api-python-client >= 1.7.8 google-cloud-firestore >= 0.31.0; platform.python_implementation != 'PyPy' google-cloud-storage >= 1.13.0 six >= 1.6.1 diff --git a/setup.py b/setup.py index 9aa36f89f..15ae97f93 100644 --- a/setup.py +++ b/setup.py @@ -39,6 +39,7 @@ install_requires = [ 'cachecontrol>=0.12.4', 'google-api-core[grpc] >= 1.7.0, < 2.0.0dev; platform.python_implementation != "PyPy"', + 'google-api-python-client >= 1.7.8', 'google-cloud-firestore>=0.31.0; platform.python_implementation != "PyPy"', 'google-cloud-storage>=1.13.0', 'six>=1.6.1' diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 8be2b8d8f..a1745f549 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -20,6 +20,9 @@ import pytest import six +import googleapiclient +from googleapiclient.http import HttpMockSequence + import firebase_admin from firebase_admin import messaging from tests import testutils @@ -38,6 +41,23 @@ def check_encoding(msg, expected=None): assert encoded == expected +class TestMulticastMessage(object): + + @pytest.mark.parametrize('tokens', NON_LIST_ARGS) + def test_invalid_tokens_type(self, tokens): + with pytest.raises(ValueError) as excinfo: + messaging.MulticastMessage(tokens=tokens) + if isinstance(tokens, list): + expected = 'MulticastMessage.tokens must not contain non-string values.' + assert str(excinfo.value) == expected + else: + expected = 'MulticastMessage.tokens must be a list of strings.' + assert str(excinfo.value) == expected + + def test_tokens_type(self): + messaging.MulticastMessage(tokens=['token']) + + class TestMessageEncoder(object): @pytest.mark.parametrize('msg', [ @@ -1316,6 +1336,378 @@ def test_send_fcm_error_code(self, status): assert json.loads(recorder[0].body.decode()) == body +class TestSendAll(object): + + _PAYLOAD_FORMAT = """--boundary\r\nContent-Type: application/http\r\nContent-ID: \r\n\r\nHTTP/1.1 {} Success\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{}\r\n\r\n--boundary--""" + _CLIENT_VERSION = 'fire-admin-python/{0}'.format(firebase_admin.__version__) + + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + def _instrument_batch_messaging_service(self, app=None, status=200, payload=''): + if not app: + app = firebase_admin.get_app() + fcm_service = messaging._get_messaging_service(app) + if status == 200: + content_type = 'multipart/mixed; boundary=boundary' + else: + content_type = 'application/json' + fcm_service._transport = HttpMockSequence([ + ({'status': str(status), 'content-type': content_type}, payload), + ]) + return fcm_service + + def _get_url(self, project_id): + return messaging._MessagingService.FCM_URL.format(project_id) + + def test_no_project_id(self): + def evaluate(): + app = firebase_admin.initialize_app(testutils.MockCredential(), name='no_project_id') + with pytest.raises(ValueError): + messaging.send_all([messaging.Message(topic='foo')], app=app) + testutils.run_without_project_id(evaluate) + + @pytest.mark.parametrize('msg', NON_LIST_ARGS) + def test_invalid_send_all(self, msg): + with pytest.raises(ValueError) as excinfo: + messaging.send_all(msg) + if isinstance(msg, list): + expected = 'Message must be an instance of messaging.Message class.' + assert str(excinfo.value) == expected + else: + expected = 'Messages must be an list of messaging.Message instances.' + assert str(excinfo.value) == expected + + def test_send_all(self): + payload = json.dumps({'name': 'message-id'}) + _ = self._instrument_batch_messaging_service(payload=self._PAYLOAD_FORMAT.format('200', payload)) + msg = messaging.Message(topic='foo') + batch_response = messaging.send_all([msg], dry_run=True) + assert batch_response.success_count is 1 + assert batch_response.failure_count is 0 + assert len(batch_response.responses) == 1 + assert [r.message_id for r in batch_response.responses] == ['message-id'] + assert all([r.success for r in batch_response.responses]) + assert not any([r.exception for r in batch_response.responses]) + + @pytest.mark.parametrize('status', HTTP_ERRORS) + def test_send_all_detailed_error(self, status): + payload = self._PAYLOAD_FORMAT.format(str(status), json.dumps({ + 'error': { + 'status': 'INVALID_ARGUMENT', + 'message': 'test error' + } + })) + _ = self._instrument_batch_messaging_service(payload=payload) + msg = messaging.Message(topic='foo') + batch_response = messaging.send_all([msg]) + assert batch_response.success_count is 0 + assert batch_response.failure_count is 1 + assert len(batch_response.responses) == 1 + assert not any([r.message_id for r in batch_response.responses]) + assert not all([r.success for r in batch_response.responses]) + exception = batch_response.responses[0].exception + assert str(exception) == 'test error' + assert str(exception.code) == 'invalid-argument' + + @pytest.mark.parametrize('status', HTTP_ERRORS) + def test_send_all_canonical_error_code(self, status): + payload = self._PAYLOAD_FORMAT.format(str(status), json.dumps({ + 'error': { + 'status': 'NOT_FOUND', + 'message': 'test error' + } + })) + _ = self._instrument_batch_messaging_service(payload=payload) + msg = messaging.Message(topic='foo') + batch_response = messaging.send_all([msg]) + assert batch_response.success_count is 0 + assert batch_response.failure_count is 1 + assert len(batch_response.responses) == 1 + assert not any([r.message_id for r in batch_response.responses]) + assert not all([r.success for r in batch_response.responses]) + exception = batch_response.responses[0].exception + assert str(exception) == 'test error' + assert str(exception.code) == 'registration-token-not-registered' + + @pytest.mark.parametrize('status', HTTP_ERRORS) + def test_send_all_fcm_error_code(self, status): + payload = self._PAYLOAD_FORMAT.format(str(status), json.dumps({ + 'error': { + 'status': 'INVALID_ARGUMENT', + 'message': 'test error', + 'details': [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'UNREGISTERED', + }, + ], + } + })) + _ = self._instrument_batch_messaging_service(payload=payload) + msg = messaging.Message(topic='foo') + batch_response = messaging.send_all([msg]) + assert batch_response.success_count is 0 + assert batch_response.failure_count is 1 + assert len(batch_response.responses) == 1 + assert not any([r.message_id for r in batch_response.responses]) + assert not all([r.success for r in batch_response.responses]) + exception = batch_response.responses[0].exception + assert str(exception) == 'test error' + assert str(exception.code) == 'registration-token-not-registered' + + @pytest.mark.parametrize('status', HTTP_ERRORS) + def test_send_all_batch_error(self, status): + _ = self._instrument_batch_messaging_service(status=status, payload='{}') + msg = messaging.Message(topic='foo') + with pytest.raises(messaging.ApiCallError) as excinfo: + messaging.send_all([msg]) + expected = 'Unexpected HTTP response with status: {0}; body: {{}}'.format(status) + assert str(excinfo.value) == expected + assert str(excinfo.value.code) == messaging._MessagingService.UNKNOWN_ERROR + + @pytest.mark.parametrize('status', HTTP_ERRORS) + def test_send_all_batch_detailed_error(self, status): + payload = json.dumps({ + 'error': { + 'status': 'INVALID_ARGUMENT', + 'message': 'test error' + } + }) + _ = self._instrument_batch_messaging_service(status=status, payload=payload) + msg = messaging.Message(topic='foo') + with pytest.raises(messaging.ApiCallError) as excinfo: + messaging.send_all([msg]) + assert str(excinfo.value) == 'test error' + assert str(excinfo.value.code) == 'invalid-argument' + + @pytest.mark.parametrize('status', HTTP_ERRORS) + def test_send_all_canonical_error_code(self, status): + payload = json.dumps({ + 'error': { + 'status': 'NOT_FOUND', + 'message': 'test error' + } + }) + _ = self._instrument_batch_messaging_service(status=status, payload=payload) + msg = messaging.Message(topic='foo') + with pytest.raises(messaging.ApiCallError) as excinfo: + messaging.send_all([msg]) + assert str(excinfo.value) == 'test error' + assert str(excinfo.value.code) == 'registration-token-not-registered' + + @pytest.mark.parametrize('status', HTTP_ERRORS) + def test_send_all_fcm_error_code(self, status): + payload = json.dumps({ + 'error': { + 'status': 'INVALID_ARGUMENT', + 'message': 'test error', + 'details': [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'UNREGISTERED', + }, + ], + } + }) + _ = self._instrument_batch_messaging_service(status=status, payload=payload) + msg = messaging.Message(topic='foo') + with pytest.raises(messaging.ApiCallError) as excinfo: + messaging.send_all([msg]) + assert str(excinfo.value) == 'test error' + assert str(excinfo.value.code) == 'registration-token-not-registered' + + +class TestSendMulticast(object): + + _PAYLOAD_FORMAT = """--boundary\r\nContent-Type: application/http\r\nContent-ID: \r\n\r\nHTTP/1.1 {} Success\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{}\r\n\r\n--boundary--""" + _CLIENT_VERSION = 'fire-admin-python/{0}'.format(firebase_admin.__version__) + + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + def _instrument_batch_messaging_service(self, app=None, status=200, payload=''): + if not app: + app = firebase_admin.get_app() + fcm_service = messaging._get_messaging_service(app) + if status == 200: + content_type = 'multipart/mixed; boundary=boundary' + else: + content_type = 'application/json' + fcm_service._transport = HttpMockSequence([ + ({'status': str(status), 'content-type': content_type}, payload), + ]) + return fcm_service + + def _get_url(self, project_id): + return messaging._MessagingService.FCM_URL.format(project_id) + + def test_no_project_id(self): + def evaluate(): + app = firebase_admin.initialize_app(testutils.MockCredential(), name='no_project_id') + with pytest.raises(ValueError): + messaging.send_all([messaging.Message(topic='foo')], app=app) + testutils.run_without_project_id(evaluate) + + @pytest.mark.parametrize('msg', NON_LIST_ARGS) + def test_invalid_send_multicast(self, msg): + with pytest.raises(ValueError) as excinfo: + messaging.send_multicast(msg) + expected = 'Message must be an instance of messaging.MulticastMessage class.' + assert str(excinfo.value) == expected + + def test_send_multicast(self): + payload = json.dumps({'name': 'message-id'}) + _ = self._instrument_batch_messaging_service(payload=self._PAYLOAD_FORMAT.format('200', payload)) + msg = messaging.MulticastMessage(tokens=['foo']) + batch_response = messaging.send_multicast(msg, dry_run=True) + assert batch_response.success_count is 1 + assert batch_response.failure_count is 0 + assert len(batch_response.responses) == 1 + assert [r.message_id for r in batch_response.responses] == ['message-id'] + assert all([r.success for r in batch_response.responses]) + assert not any([r.exception for r in batch_response.responses]) + + @pytest.mark.parametrize('status', HTTP_ERRORS) + def test_send_multicast_detailed_error(self, status): + payload = self._PAYLOAD_FORMAT.format(str(status), json.dumps({ + 'error': { + 'status': 'INVALID_ARGUMENT', + 'message': 'test error' + } + })) + _ = self._instrument_batch_messaging_service(payload=payload) + msg = messaging.MulticastMessage(tokens=['foo']) + batch_response = messaging.send_multicast(msg) + assert batch_response.success_count is 0 + assert batch_response.failure_count is 1 + assert len(batch_response.responses) == 1 + assert not any([r.message_id for r in batch_response.responses]) + assert not all([r.success for r in batch_response.responses]) + exception = batch_response.responses[0].exception + assert str(exception) == 'test error' + assert str(exception.code) == 'invalid-argument' + + @pytest.mark.parametrize('status', HTTP_ERRORS) + def test_send_multicast_canonical_error_code(self, status): + payload = self._PAYLOAD_FORMAT.format(str(status), json.dumps({ + 'error': { + 'status': 'NOT_FOUND', + 'message': 'test error' + } + })) + _ = self._instrument_batch_messaging_service(payload=payload) + msg = messaging.MulticastMessage(tokens=['foo']) + batch_response = messaging.send_multicast(msg) + assert batch_response.success_count is 0 + assert batch_response.failure_count is 1 + assert len(batch_response.responses) == 1 + assert not any([r.message_id for r in batch_response.responses]) + assert not all([r.success for r in batch_response.responses]) + exception = batch_response.responses[0].exception + assert str(exception) == 'test error' + assert str(exception.code) == 'registration-token-not-registered' + + @pytest.mark.parametrize('status', HTTP_ERRORS) + def test_send_multicast_fcm_error_code(self, status): + payload = self._PAYLOAD_FORMAT.format(str(status), json.dumps({ + 'error': { + 'status': 'INVALID_ARGUMENT', + 'message': 'test error', + 'details': [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'UNREGISTERED', + }, + ], + } + })) + _ = self._instrument_batch_messaging_service(payload=payload) + msg = messaging.MulticastMessage(tokens=['foo']) + batch_response = messaging.send_multicast(msg) + assert batch_response.success_count is 0 + assert batch_response.failure_count is 1 + assert len(batch_response.responses) == 1 + assert not any([r.message_id for r in batch_response.responses]) + assert not all([r.success for r in batch_response.responses]) + exception = batch_response.responses[0].exception + assert str(exception) == 'test error' + assert str(exception.code) == 'registration-token-not-registered' + + @pytest.mark.parametrize('status', HTTP_ERRORS) + def test_send_multicast_batch_error(self, status): + _ = self._instrument_batch_messaging_service(status=status, payload='{}') + msg = messaging.MulticastMessage(tokens=['foo']) + with pytest.raises(messaging.ApiCallError) as excinfo: + messaging.send_multicast(msg) + expected = 'Unexpected HTTP response with status: {0}; body: {{}}'.format(status) + assert str(excinfo.value) == expected + assert str(excinfo.value.code) == messaging._MessagingService.UNKNOWN_ERROR + + @pytest.mark.parametrize('status', HTTP_ERRORS) + def test_send_multicast_batch_detailed_error(self, status): + payload = json.dumps({ + 'error': { + 'status': 'INVALID_ARGUMENT', + 'message': 'test error' + } + }) + _ = self._instrument_batch_messaging_service(status=status, payload=payload) + msg = messaging.MulticastMessage(tokens=['foo']) + with pytest.raises(messaging.ApiCallError) as excinfo: + messaging.send_multicast(msg) + assert str(excinfo.value) == 'test error' + assert str(excinfo.value.code) == 'invalid-argument' + + @pytest.mark.parametrize('status', HTTP_ERRORS) + def test_send_multicast_canonical_error_code(self, status): + payload = json.dumps({ + 'error': { + 'status': 'NOT_FOUND', + 'message': 'test error' + } + }) + _ = self._instrument_batch_messaging_service(status=status, payload=payload) + msg = messaging.MulticastMessage(tokens=['foo']) + with pytest.raises(messaging.ApiCallError) as excinfo: + messaging.send_multicast(msg) + assert str(excinfo.value) == 'test error' + assert str(excinfo.value.code) == 'registration-token-not-registered' + + @pytest.mark.parametrize('status', HTTP_ERRORS) + def test_send_multicast_fcm_error_code(self, status): + payload = json.dumps({ + 'error': { + 'status': 'INVALID_ARGUMENT', + 'message': 'test error', + 'details': [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'UNREGISTERED', + }, + ], + } + }) + _ = self._instrument_batch_messaging_service(status=status, payload=payload) + msg = messaging.MulticastMessage(tokens=['foo']) + with pytest.raises(messaging.ApiCallError) as excinfo: + messaging.send_multicast(msg) + assert str(excinfo.value) == 'test error' + assert str(excinfo.value.code) == 'registration-token-not-registered' + + class TestTopicManagement(object): _DEFAULT_RESPONSE = json.dumps({'results': [{}, {'error': 'error_reason'}]}) From fd6822c5239fca97700d2c5cd01ce1429cdb8689 Mon Sep 17 00:00:00 2001 From: Zachary Orr Date: Fri, 3 May 2019 09:34:01 -0400 Subject: [PATCH 2/8] Fix CI --- firebase_admin/messaging.py | 35 ++++++++++++++++++++++++++--------- tests/test_messaging.py | 23 ++++++++++++++--------- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index bec17bcf8..a488539a0 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -120,13 +120,13 @@ def send_all(messages, dry_run=False, app=None): return _get_messaging_service(app).send_all(messages, dry_run) def send_multicast(multicast_message, dry_run=False, app=None): - """Sends the given mutlicast message to the mutlicast message tokens via Firebase Cloud Messaging (FCM). + """Sends the given mutlicast message to all tokens via Firebase Cloud Messaging (FCM). If the ``dry_run`` mode is enabled, the message will not be actually delivered to the recipients. Instead FCM performs all the usual validations, and emulates the send operation. Args: - message: An instance of ``messaging.MulticastMessage``. + multicast_message: An instance of ``messaging.MulticastMessage``. dry_run: A boolean indicating whether to run the operation in dry run mode (optional). app: An App instance (optional). @@ -139,14 +139,14 @@ def send_multicast(multicast_message, dry_run=False, app=None): """ if not isinstance(multicast_message, MulticastMessage): raise ValueError('Message must be an instance of messaging.MulticastMessage class.') - messages = map(lambda token: Message( + messages = [Message( data=multicast_message.data, notification=multicast_message.notification, android=multicast_message.android, webpush=multicast_message.webpush, apns=multicast_message.apns, token=token - ), multicast_message.tokens) + ) for token in multicast_message.tokens] return _get_messaging_service(app).send_all(messages, dry_run) def subscribe_to_topic(tokens, topic, app=None): @@ -254,6 +254,7 @@ def __init__(self, code, message, detail=None): class BatchResponse(object): + """The response received from a batch request to the FCM API.""" def __init__(self, responses): self._responses = responses @@ -277,6 +278,7 @@ def failure_count(self): class SendResponse(object): + """The response received from an individual batched request to the FCM API.""" def __init__(self, resp, exception): self._exception = exception @@ -361,7 +363,12 @@ def send(self, message, dry_run=False): data = self._message_data(message, dry_run) try: resp = self._client.body( - 'post', url=self._fcm_url, headers=self._fcm_headers, json=data, timeout=self._timeout) + 'post', + url=self._fcm_url, + headers=self._fcm_headers, + json=data, + timeout=self._timeout + ) except requests.exceptions.RequestException as error: if error.response is not None: self._handle_fcm_error(error) @@ -372,12 +379,13 @@ def send(self, message, dry_run=False): return resp['name'] def send_all(self, messages, dry_run=False): + """Sends the given messages to FCM via the batch API.""" if not isinstance(messages, list): raise ValueError('Messages must be an list of messaging.Message instances.') responses = [] - def batch_callback(request_id, response, error): + def batch_callback(_, response, error): exception = None if error: exception = self._parse_batch_error(error) @@ -388,7 +396,13 @@ def batch_callback(request_id, response, error): for message in messages: body = json.dumps(self._message_data(message, dry_run)) req = http.HttpRequest( - http=self._transport, postproc=self._postproc, uri=self._fcm_url, method='POST', body=body, headers=self._fcm_headers) + http=self._transport, + postproc=self._postproc, + uri=self._fcm_url, + method='POST', + body=body, + headers=self._fcm_headers + ) batch.add(req) try: @@ -455,7 +469,8 @@ def _handle_fcm_error(self, error): except ValueError: pass - raise _MessagingService._parse_fcm_error(data, error.response.content, error.response.status_code, error) + raise _MessagingService._parse_fcm_error( + data, error.response.content, error.response.status_code, error) def _handle_iid_error(self, error): """Handles errors received from the Instance ID API.""" @@ -476,6 +491,7 @@ def _handle_iid_error(self, error): raise ApiCallError(code, msg, error) def _parse_batch_error(self, error): + """Parses a googleapiclient.http.HttpError content in to an ApiCallError.""" if error.content is None: msg = 'Failed to call messaging API: {0}'.format(error) return ApiCallError(self.INTERNAL_ERROR, msg, error) @@ -504,6 +520,7 @@ def _parse_fcm_error(cls, data, content, status_code, error): msg = error_dict.get('message') if not msg: - msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(status_code, content.decode()) + msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format( + status_code, content.decode()) return ApiCallError(code, msg, error) diff --git a/tests/test_messaging.py b/tests/test_messaging.py index a1745f549..134b8b29c 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -20,7 +20,6 @@ import pytest import six -import googleapiclient from googleapiclient.http import HttpMockSequence import firebase_admin @@ -1338,7 +1337,9 @@ def test_send_fcm_error_code(self, status): class TestSendAll(object): - _PAYLOAD_FORMAT = """--boundary\r\nContent-Type: application/http\r\nContent-ID: \r\n\r\nHTTP/1.1 {} Success\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{}\r\n\r\n--boundary--""" + _PAYLOAD_FORMAT = """--boundary\r\nContent-Type: application/http\r\n\ +Content-ID: \r\n\r\nHTTP/1.1 {} Success\r\n\ +Content-Type: application/json; charset=UTF-8\r\n\r\n{}\r\n\r\n--boundary--""" _CLIENT_VERSION = 'fire-admin-python/{0}'.format(firebase_admin.__version__) @classmethod @@ -1386,7 +1387,8 @@ def test_invalid_send_all(self, msg): def test_send_all(self): payload = json.dumps({'name': 'message-id'}) - _ = self._instrument_batch_messaging_service(payload=self._PAYLOAD_FORMAT.format('200', payload)) + _ = self._instrument_batch_messaging_service( + payload=self._PAYLOAD_FORMAT.format('200', payload)) msg = messaging.Message(topic='foo') batch_response = messaging.send_all([msg], dry_run=True) assert batch_response.success_count is 1 @@ -1488,7 +1490,7 @@ def test_send_all_batch_detailed_error(self, status): assert str(excinfo.value.code) == 'invalid-argument' @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_send_all_canonical_error_code(self, status): + def test_send_all_batch_canonical_error_code(self, status): payload = json.dumps({ 'error': { 'status': 'NOT_FOUND', @@ -1503,7 +1505,7 @@ def test_send_all_canonical_error_code(self, status): assert str(excinfo.value.code) == 'registration-token-not-registered' @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_send_all_fcm_error_code(self, status): + def test_send_all_batch_fcm_error_code(self, status): payload = json.dumps({ 'error': { 'status': 'INVALID_ARGUMENT', @@ -1526,7 +1528,9 @@ def test_send_all_fcm_error_code(self, status): class TestSendMulticast(object): - _PAYLOAD_FORMAT = """--boundary\r\nContent-Type: application/http\r\nContent-ID: \r\n\r\nHTTP/1.1 {} Success\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{}\r\n\r\n--boundary--""" + _PAYLOAD_FORMAT = """--boundary\r\nContent-Type: application/http\r\n\ +Content-ID: \r\n\r\nHTTP/1.1 {} Success\r\n\ +Content-Type: application/json; charset=UTF-8\r\n\r\n{}\r\n\r\n--boundary--""" _CLIENT_VERSION = 'fire-admin-python/{0}'.format(firebase_admin.__version__) @classmethod @@ -1570,7 +1574,8 @@ def test_invalid_send_multicast(self, msg): def test_send_multicast(self): payload = json.dumps({'name': 'message-id'}) - _ = self._instrument_batch_messaging_service(payload=self._PAYLOAD_FORMAT.format('200', payload)) + _ = self._instrument_batch_messaging_service( + payload=self._PAYLOAD_FORMAT.format('200', payload)) msg = messaging.MulticastMessage(tokens=['foo']) batch_response = messaging.send_multicast(msg, dry_run=True) assert batch_response.success_count is 1 @@ -1672,7 +1677,7 @@ def test_send_multicast_batch_detailed_error(self, status): assert str(excinfo.value.code) == 'invalid-argument' @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_send_multicast_canonical_error_code(self, status): + def test_send_multicast_batch_canonical_error_code(self, status): payload = json.dumps({ 'error': { 'status': 'NOT_FOUND', @@ -1687,7 +1692,7 @@ def test_send_multicast_canonical_error_code(self, status): assert str(excinfo.value.code) == 'registration-token-not-registered' @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_send_multicast_fcm_error_code(self, status): + def test_send_multicast_batch_fcm_error_code(self, status): payload = json.dumps({ 'error': { 'status': 'INVALID_ARGUMENT', From 97d0b847ec2951fa3f43d8fe59d4840f42e5a2f9 Mon Sep 17 00:00:00 2001 From: Zachary Orr Date: Fri, 3 May 2019 15:16:21 -0400 Subject: [PATCH 3/8] Small changes --- firebase_admin/_messaging_utils.py | 2 +- firebase_admin/messaging.py | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/firebase_admin/_messaging_utils.py b/firebase_admin/_messaging_utils.py index 63623e33e..373adf68c 100644 --- a/firebase_admin/_messaging_utils.py +++ b/firebase_admin/_messaging_utils.py @@ -72,7 +72,7 @@ class MulticastMessage(object): def __init__(self, tokens, data=None, notification=None, android=None, webpush=None, apns=None): _Validators.check_string_list('MulticastMessage.tokens', tokens) if len(tokens) > 100: - raise ValueError('MulticastMessage.tokens must contain less than 100 tokens.') + raise ValueError('MulticastMessage.tokens must not contain more than 100 tokens.') self.tokens = tokens self.data = data self.notification = notification diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index a488539a0..e262a9547 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -100,7 +100,7 @@ def send(message, dry_run=False, app=None): return _get_messaging_service(app).send(message, dry_run) def send_all(messages, dry_run=False, app=None): - """Batch sends the given messages via Firebase Cloud Messaging (FCM). + """Sends the given list of messages via Firebase Cloud Messaging as a single batch. If the ``dry_run`` mode is enabled, the message will not be actually delivered to the recipients. Instead FCM performs all the usual validations, and emulates the send operation. @@ -258,10 +258,7 @@ class BatchResponse(object): def __init__(self, responses): self._responses = responses - self._success_count = 0 - for response in responses: - if response.success: - self._success_count += 1 + self._success_count = len([resp for resp in responses if resp.success]) @property def responses(self): @@ -455,7 +452,7 @@ def _message_data(self, message, dry_run): def _postproc(self, resp, body): if resp.status == 200: - return json.loads(body) + return json.loads(body.decode()) else: raise Exception('unexpected response') @@ -498,7 +495,7 @@ def _parse_batch_error(self, error): data = {} try: - parsed_body = json.loads(error.content) + parsed_body = json.loads(error.content.decode()) if isinstance(parsed_body, dict): data = parsed_body except ValueError: From 07bd5ac9d59a7dc91b644ed2a749914e9b42b86e Mon Sep 17 00:00:00 2001 From: Zachary Orr Date: Fri, 3 May 2019 16:41:50 -0400 Subject: [PATCH 4/8] Updating tests --- firebase_admin/messaging.py | 2 + tests/test_messaging.py | 246 +++++++++++++++++++++--------------- 2 files changed, 144 insertions(+), 104 deletions(-) diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index e262a9547..deed14a38 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -379,6 +379,8 @@ def send_all(self, messages, dry_run=False): """Sends the given messages to FCM via the batch API.""" if not isinstance(messages, list): raise ValueError('Messages must be an list of messaging.Message instances.') + if len(messages) > 100: + raise ValueError('send_all messages must not contain more than 100 messages.') responses = [] diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 134b8b29c..bfb719658 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -53,8 +53,15 @@ def test_invalid_tokens_type(self, tokens): expected = 'MulticastMessage.tokens must be a list of strings.' assert str(excinfo.value) == expected + def test_tokens_over_one_hundred(self): + with pytest.raises(ValueError) as excinfo: + messaging.MulticastMessage(tokens=['token' for i in xrange(0, 101)]) + expected = 'MulticastMessage.tokens must not contain more than 100 tokens.' + assert str(excinfo.value) == expected + def test_tokens_type(self): messaging.MulticastMessage(tokens=['token']) + messaging.MulticastMessage(tokens=['token' for i in xrange(0, 100)]) class TestMessageEncoder(object): @@ -1335,12 +1342,7 @@ def test_send_fcm_error_code(self, status): assert json.loads(recorder[0].body.decode()) == body -class TestSendAll(object): - - _PAYLOAD_FORMAT = """--boundary\r\nContent-Type: application/http\r\n\ -Content-ID: \r\n\r\nHTTP/1.1 {} Success\r\n\ -Content-Type: application/json; charset=UTF-8\r\n\r\n{}\r\n\r\n--boundary--""" - _CLIENT_VERSION = 'fire-admin-python/{0}'.format(firebase_admin.__version__) +class TestBatch(object): @classmethod def setup_class(cls): @@ -1364,8 +1366,19 @@ def _instrument_batch_messaging_service(self, app=None, status=200, payload=''): ]) return fcm_service - def _get_url(self, project_id): - return messaging._MessagingService.FCM_URL.format(project_id) + def _batch_payload(self, payloads): + # payloads should be a list of (status_code, content) tuples + payload = '' + _playload_format = """--boundary\r\nContent-Type: application/http\r\n\ +Content-ID: \r\n\r\nHTTP/1.1 {} Success\r\n\ +Content-Type: application/json; charset=UTF-8\r\n\r\n{}\r\n\r\n""" + for (index, (status_code, content)) in enumerate(payloads): + payload += _playload_format.format(str(index + 1), str(status_code), content) + payload += '--boundary--' + return payload + + +class TestSendAll(TestBatch): def test_no_project_id(self): def evaluate(): @@ -1385,62 +1398,86 @@ def test_invalid_send_all(self, msg): expected = 'Messages must be an list of messaging.Message instances.' assert str(excinfo.value) == expected + def test_invalid_over_one_hundred(self): + msg = messaging.Message(topic='foo') + with pytest.raises(ValueError) as excinfo: + messaging.send_all([msg for i in xrange(0, 101)]) + expected = 'send_all messages must not contain more than 100 messages.' + assert str(excinfo.value) == expected + def test_send_all(self): payload = json.dumps({'name': 'message-id'}) _ = self._instrument_batch_messaging_service( - payload=self._PAYLOAD_FORMAT.format('200', payload)) + payload=self._batch_payload([(200, payload), (200, payload)])) msg = messaging.Message(topic='foo') - batch_response = messaging.send_all([msg], dry_run=True) - assert batch_response.success_count is 1 + batch_response = messaging.send_all([msg, msg], dry_run=True) + assert batch_response.success_count is 2 assert batch_response.failure_count is 0 - assert len(batch_response.responses) == 1 - assert [r.message_id for r in batch_response.responses] == ['message-id'] + assert len(batch_response.responses) == 2 + assert [r.message_id for r in batch_response.responses] == ['message-id', 'message-id'] assert all([r.success for r in batch_response.responses]) assert not any([r.exception for r in batch_response.responses]) @pytest.mark.parametrize('status', HTTP_ERRORS) def test_send_all_detailed_error(self, status): - payload = self._PAYLOAD_FORMAT.format(str(status), json.dumps({ + success_payload = json.dumps({'name': 'message-id'}) + error_payload = json.dumps({ 'error': { 'status': 'INVALID_ARGUMENT', 'message': 'test error' } - })) - _ = self._instrument_batch_messaging_service(payload=payload) + }) + _ = self._instrument_batch_messaging_service( + payload=self._batch_payload([(200, success_payload), (status, error_payload)])) msg = messaging.Message(topic='foo') - batch_response = messaging.send_all([msg]) - assert batch_response.success_count is 0 + batch_response = messaging.send_all([msg, msg]) + assert batch_response.success_count is 1 assert batch_response.failure_count is 1 - assert len(batch_response.responses) == 1 - assert not any([r.message_id for r in batch_response.responses]) - assert not all([r.success for r in batch_response.responses]) - exception = batch_response.responses[0].exception + assert len(batch_response.responses) == 2 + success_response = batch_response.responses[0] + assert success_response.message_id == 'message-id' + assert success_response.success + assert success_response.exception is None + error_response = batch_response.responses[1] + assert error_response.message_id is None + assert not error_response.success + assert error_response.exception + exception = error_response.exception assert str(exception) == 'test error' assert str(exception.code) == 'invalid-argument' @pytest.mark.parametrize('status', HTTP_ERRORS) def test_send_all_canonical_error_code(self, status): - payload = self._PAYLOAD_FORMAT.format(str(status), json.dumps({ + success_payload = json.dumps({'name': 'message-id'}) + error_payload = json.dumps({ 'error': { 'status': 'NOT_FOUND', 'message': 'test error' } - })) - _ = self._instrument_batch_messaging_service(payload=payload) + }) + _ = self._instrument_batch_messaging_service( + payload=self._batch_payload([(200, success_payload), (status, error_payload)])) msg = messaging.Message(topic='foo') - batch_response = messaging.send_all([msg]) - assert batch_response.success_count is 0 + batch_response = messaging.send_all([msg, msg]) + assert batch_response.success_count is 1 assert batch_response.failure_count is 1 - assert len(batch_response.responses) == 1 - assert not any([r.message_id for r in batch_response.responses]) - assert not all([r.success for r in batch_response.responses]) - exception = batch_response.responses[0].exception + assert len(batch_response.responses) == 2 + success_response = batch_response.responses[0] + assert success_response.message_id == 'message-id' + assert success_response.success + assert success_response.exception is None + error_response = batch_response.responses[1] + assert error_response.message_id is None + assert not error_response.success + assert error_response.exception + exception = error_response.exception assert str(exception) == 'test error' assert str(exception.code) == 'registration-token-not-registered' @pytest.mark.parametrize('status', HTTP_ERRORS) def test_send_all_fcm_error_code(self, status): - payload = self._PAYLOAD_FORMAT.format(str(status), json.dumps({ + success_payload = json.dumps({'name': 'message-id'}) + error_payload = json.dumps({ 'error': { 'status': 'INVALID_ARGUMENT', 'message': 'test error', @@ -1451,16 +1488,23 @@ def test_send_all_fcm_error_code(self, status): }, ], } - })) - _ = self._instrument_batch_messaging_service(payload=payload) + }) + _ = self._instrument_batch_messaging_service( + payload=self._batch_payload([(200, success_payload), (status, error_payload)])) msg = messaging.Message(topic='foo') - batch_response = messaging.send_all([msg]) - assert batch_response.success_count is 0 + batch_response = messaging.send_all([msg, msg]) + assert batch_response.success_count is 1 assert batch_response.failure_count is 1 - assert len(batch_response.responses) == 1 - assert not any([r.message_id for r in batch_response.responses]) - assert not all([r.success for r in batch_response.responses]) - exception = batch_response.responses[0].exception + assert len(batch_response.responses) == 2 + success_response = batch_response.responses[0] + assert success_response.message_id == 'message-id' + assert success_response.success + assert success_response.exception is None + error_response = batch_response.responses[1] + assert error_response.message_id is None + assert not error_response.success + assert error_response.exception + exception = error_response.exception assert str(exception) == 'test error' assert str(exception.code) == 'registration-token-not-registered' @@ -1526,37 +1570,7 @@ def test_send_all_batch_fcm_error_code(self, status): assert str(excinfo.value.code) == 'registration-token-not-registered' -class TestSendMulticast(object): - - _PAYLOAD_FORMAT = """--boundary\r\nContent-Type: application/http\r\n\ -Content-ID: \r\n\r\nHTTP/1.1 {} Success\r\n\ -Content-Type: application/json; charset=UTF-8\r\n\r\n{}\r\n\r\n--boundary--""" - _CLIENT_VERSION = 'fire-admin-python/{0}'.format(firebase_admin.__version__) - - @classmethod - def setup_class(cls): - cred = testutils.MockCredential() - firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'}) - - @classmethod - def teardown_class(cls): - testutils.cleanup_apps() - - def _instrument_batch_messaging_service(self, app=None, status=200, payload=''): - if not app: - app = firebase_admin.get_app() - fcm_service = messaging._get_messaging_service(app) - if status == 200: - content_type = 'multipart/mixed; boundary=boundary' - else: - content_type = 'application/json' - fcm_service._transport = HttpMockSequence([ - ({'status': str(status), 'content-type': content_type}, payload), - ]) - return fcm_service - - def _get_url(self, project_id): - return messaging._MessagingService.FCM_URL.format(project_id) +class TestSendMulticast(TestBatch): def test_no_project_id(self): def evaluate(): @@ -1575,59 +1589,76 @@ def test_invalid_send_multicast(self, msg): def test_send_multicast(self): payload = json.dumps({'name': 'message-id'}) _ = self._instrument_batch_messaging_service( - payload=self._PAYLOAD_FORMAT.format('200', payload)) - msg = messaging.MulticastMessage(tokens=['foo']) + payload=self._batch_payload([(200, payload), (200, payload)])) + msg = messaging.MulticastMessage(tokens=['foo', 'foo']) batch_response = messaging.send_multicast(msg, dry_run=True) - assert batch_response.success_count is 1 + assert batch_response.success_count is 2 assert batch_response.failure_count is 0 - assert len(batch_response.responses) == 1 - assert [r.message_id for r in batch_response.responses] == ['message-id'] + assert len(batch_response.responses) == 2 + assert [r.message_id for r in batch_response.responses] == ['message-id', 'message-id'] assert all([r.success for r in batch_response.responses]) assert not any([r.exception for r in batch_response.responses]) @pytest.mark.parametrize('status', HTTP_ERRORS) def test_send_multicast_detailed_error(self, status): - payload = self._PAYLOAD_FORMAT.format(str(status), json.dumps({ + success_payload = json.dumps({'name': 'message-id'}) + error_payload = json.dumps({ 'error': { 'status': 'INVALID_ARGUMENT', 'message': 'test error' } - })) - _ = self._instrument_batch_messaging_service(payload=payload) - msg = messaging.MulticastMessage(tokens=['foo']) + }) + _ = self._instrument_batch_messaging_service( + payload=self._batch_payload([(200, success_payload), (status, error_payload)])) + msg = messaging.MulticastMessage(tokens=['foo', 'foo']) batch_response = messaging.send_multicast(msg) - assert batch_response.success_count is 0 + assert batch_response.success_count is 1 assert batch_response.failure_count is 1 - assert len(batch_response.responses) == 1 - assert not any([r.message_id for r in batch_response.responses]) - assert not all([r.success for r in batch_response.responses]) - exception = batch_response.responses[0].exception + assert len(batch_response.responses) == 2 + success_response = batch_response.responses[0] + assert success_response.message_id == 'message-id' + assert success_response.success + assert success_response.exception is None + error_response = batch_response.responses[1] + assert error_response.message_id is None + assert not error_response.success + assert error_response.exception + exception = error_response.exception assert str(exception) == 'test error' assert str(exception.code) == 'invalid-argument' @pytest.mark.parametrize('status', HTTP_ERRORS) def test_send_multicast_canonical_error_code(self, status): - payload = self._PAYLOAD_FORMAT.format(str(status), json.dumps({ + success_payload = json.dumps({'name': 'message-id'}) + error_payload = json.dumps({ 'error': { 'status': 'NOT_FOUND', 'message': 'test error' } - })) - _ = self._instrument_batch_messaging_service(payload=payload) - msg = messaging.MulticastMessage(tokens=['foo']) + }) + _ = self._instrument_batch_messaging_service( + payload=self._batch_payload([(200, success_payload), (status, error_payload)])) + msg = messaging.MulticastMessage(tokens=['foo', 'foo']) batch_response = messaging.send_multicast(msg) - assert batch_response.success_count is 0 + assert batch_response.success_count is 1 assert batch_response.failure_count is 1 - assert len(batch_response.responses) == 1 - assert not any([r.message_id for r in batch_response.responses]) - assert not all([r.success for r in batch_response.responses]) - exception = batch_response.responses[0].exception + assert len(batch_response.responses) == 2 + success_response = batch_response.responses[0] + assert success_response.message_id == 'message-id' + assert success_response.success + assert success_response.exception is None + error_response = batch_response.responses[1] + assert error_response.message_id is None + assert not error_response.success + assert error_response.exception + exception = error_response.exception assert str(exception) == 'test error' assert str(exception.code) == 'registration-token-not-registered' @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_send_multicast_fcm_error_code(self, status): - payload = self._PAYLOAD_FORMAT.format(str(status), json.dumps({ + def test_send_multicast_canonical_error_code(self, status): + success_payload = json.dumps({'name': 'message-id'}) + error_payload = json.dumps({ 'error': { 'status': 'INVALID_ARGUMENT', 'message': 'test error', @@ -1638,16 +1669,23 @@ def test_send_multicast_fcm_error_code(self, status): }, ], } - })) - _ = self._instrument_batch_messaging_service(payload=payload) - msg = messaging.MulticastMessage(tokens=['foo']) + }) + _ = self._instrument_batch_messaging_service( + payload=self._batch_payload([(200, success_payload), (status, error_payload)])) + msg = messaging.MulticastMessage(tokens=['foo', 'foo']) batch_response = messaging.send_multicast(msg) - assert batch_response.success_count is 0 + assert batch_response.success_count is 1 assert batch_response.failure_count is 1 - assert len(batch_response.responses) == 1 - assert not any([r.message_id for r in batch_response.responses]) - assert not all([r.success for r in batch_response.responses]) - exception = batch_response.responses[0].exception + assert len(batch_response.responses) == 2 + success_response = batch_response.responses[0] + assert success_response.message_id == 'message-id' + assert success_response.success + assert success_response.exception is None + error_response = batch_response.responses[1] + assert error_response.message_id is None + assert not error_response.success + assert error_response.exception + exception = error_response.exception assert str(exception) == 'test error' assert str(exception.code) == 'registration-token-not-registered' From 07376bdbf043ec144b3af559173dd0e330495614 Mon Sep 17 00:00:00 2001 From: Zachary Orr Date: Fri, 3 May 2019 22:22:58 -0400 Subject: [PATCH 5/8] Add non-200 non-error response code tests --- firebase_admin/messaging.py | 29 +++++--- tests/test_messaging.py | 132 ++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 10 deletions(-) diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index deed14a38..2c854446e 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -453,10 +453,17 @@ def _message_data(self, message, dry_run): return data def _postproc(self, resp, body): - if resp.status == 200: - return json.loads(body.decode()) - else: - raise Exception('unexpected response') + if resp.status is not 200: + data = {} + try: + parsed_body = json.loads(body.decode()) + if isinstance(parsed_body, dict): + data = parsed_body + except ValueError: + pass + code, msg = _MessagingService._parse_fcm_error(data, body, resp.status) + raise ApiCallError(code, msg) + return json.loads(body.decode()) def _handle_fcm_error(self, error): """Handles errors received from the FCM API.""" @@ -468,8 +475,9 @@ def _handle_fcm_error(self, error): except ValueError: pass - raise _MessagingService._parse_fcm_error( - data, error.response.content, error.response.status_code, error) + code, msg = _MessagingService._parse_fcm_error( + data, error.response.content, error.response.status_code) + raise ApiCallError(code, msg, error) def _handle_iid_error(self, error): """Handles errors received from the Instance ID API.""" @@ -502,10 +510,12 @@ def _parse_batch_error(self, error): data = parsed_body except ValueError: pass - return _MessagingService._parse_fcm_error(data, error.content, error.resp.status, error) + + code, msg = _MessagingService._parse_fcm_error(data, error.content, error.resp.status) + return ApiCallError(code, msg, error) @classmethod - def _parse_fcm_error(cls, data, content, status_code, error): + def _parse_fcm_error(cls, data, content, status_code): """Parses an error response from the FCM API to a ApiCallError.""" error_dict = data.get('error', {}) server_code = None @@ -521,5 +531,4 @@ def _parse_fcm_error(cls, data, content, status_code, error): if not msg: msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format( status_code, content.decode()) - - return ApiCallError(code, msg, error) + return code, msg diff --git a/tests/test_messaging.py b/tests/test_messaging.py index bfb719658..1bfa842f9 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -1418,6 +1418,72 @@ def test_send_all(self): assert all([r.success for r in batch_response.responses]) assert not any([r.exception for r in batch_response.responses]) + def test_send_all_non_error_non_200(self): + success_payload = json.dumps({'name': 'message-id'}) + error_payload = json.dumps({}) + _ = self._instrument_batch_messaging_service( + payload=self._batch_payload([(200, success_payload), (202, error_payload)])) + msg = messaging.Message(topic='foo') + with pytest.raises(messaging.ApiCallError) as excinfo: + messaging.send_all([msg, msg], dry_run=True) + assert str(excinfo.value) == 'Unexpected HTTP response with status: 202; body: {}\r\n' + assert str(excinfo.value.code) == messaging._MessagingService.UNKNOWN_ERROR + + def test_send_all_non_error_non_200_detailed_error(self): + success_payload = json.dumps({'name': 'message-id'}) + error_payload = json.dumps({ + 'error': { + 'status': 'INVALID_ARGUMENT', + 'message': 'test error' + } + }) + _ = self._instrument_batch_messaging_service( + payload=self._batch_payload([(200, success_payload), (202, error_payload)])) + msg = messaging.Message(topic='foo') + with pytest.raises(messaging.ApiCallError) as excinfo: + batch_response = messaging.send_all([msg, msg], dry_run=True) + assert str(excinfo.value) == 'test error' + assert str(excinfo.value.code) == 'invalid-argument' + + def test_send_all_non_error_non_200_canonical_error_code(self): + success_payload = json.dumps({'name': 'message-id'}) + error_payload = json.dumps({ + 'error': { + 'status': 'NOT_FOUND', + 'message': 'test error' + } + }) + _ = self._instrument_batch_messaging_service( + payload=self._batch_payload([(200, success_payload), (202, error_payload)])) + msg = messaging.Message(topic='foo') + with pytest.raises(messaging.ApiCallError) as excinfo: + messaging.send_all([msg, msg], dry_run=True) + expected = 'send_all messages must not contain more than 100 messages.' + assert str(excinfo.value) == 'test error' + assert str(excinfo.value.code) == 'registration-token-not-registered' + + def test_send_all_non_error_non_200_fcm_error_code(self): + success_payload = json.dumps({'name': 'message-id'}) + error_payload = json.dumps({ + 'error': { + 'status': 'INVALID_ARGUMENT', + 'message': 'test error', + 'details': [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'UNREGISTERED', + }, + ], + } + }) + _ = self._instrument_batch_messaging_service( + payload=self._batch_payload([(200, success_payload), (202, error_payload)])) + msg = messaging.Message(topic='foo') + with pytest.raises(messaging.ApiCallError) as excinfo: + messaging.send_all([msg, msg], dry_run=True) + assert str(excinfo.value) == 'test error' + assert str(excinfo.value.code) == 'registration-token-not-registered' + @pytest.mark.parametrize('status', HTTP_ERRORS) def test_send_all_detailed_error(self, status): success_payload = json.dumps({'name': 'message-id'}) @@ -1599,6 +1665,72 @@ def test_send_multicast(self): assert all([r.success for r in batch_response.responses]) assert not any([r.exception for r in batch_response.responses]) + def test_send_multicast_non_error_non_200(self): + success_payload = json.dumps({'name': 'message-id'}) + error_payload = json.dumps({}) + _ = self._instrument_batch_messaging_service( + payload=self._batch_payload([(200, success_payload), (202, error_payload)])) + msg = messaging.MulticastMessage(tokens=['foo', 'foo']) + with pytest.raises(messaging.ApiCallError) as excinfo: + messaging.send_multicast(msg, dry_run=True) + expected = 'Unexpected HTTP response with status: 202; body: {}\r\n' + assert str(excinfo.value) == expected + assert str(excinfo.value.code) == messaging._MessagingService.UNKNOWN_ERROR + + def test_send_multicast_non_error_non_200_detailed_error(self): + success_payload = json.dumps({'name': 'message-id'}) + error_payload = json.dumps({ + 'error': { + 'status': 'INVALID_ARGUMENT', + 'message': 'test error' + } + }) + _ = self._instrument_batch_messaging_service( + payload=self._batch_payload([(200, success_payload), (202, error_payload)])) + msg = messaging.MulticastMessage(tokens=['foo', 'foo']) + with pytest.raises(messaging.ApiCallError) as excinfo: + batch_response = messaging.send_multicast(msg, dry_run=True) + assert str(excinfo.value) == 'test error' + assert str(excinfo.value.code) == 'invalid-argument' + + def test_send_multicast_non_error_non_200_canonical_error_code(self): + success_payload = json.dumps({'name': 'message-id'}) + error_payload = json.dumps({ + 'error': { + 'status': 'NOT_FOUND', + 'message': 'test error' + } + }) + _ = self._instrument_batch_messaging_service( + payload=self._batch_payload([(200, success_payload), (202, error_payload)])) + msg = messaging.MulticastMessage(tokens=['foo', 'foo']) + with pytest.raises(messaging.ApiCallError) as excinfo: + messaging.send_multicast(msg, dry_run=True) + assert str(excinfo.value) == 'test error' + assert str(excinfo.value.code) == 'registration-token-not-registered' + + def test_send_multicast_non_error_non_200_fcm_error_code(self): + success_payload = json.dumps({'name': 'message-id'}) + error_payload = json.dumps({ + 'error': { + 'status': 'INVALID_ARGUMENT', + 'message': 'test error', + 'details': [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'UNREGISTERED', + }, + ], + } + }) + _ = self._instrument_batch_messaging_service( + payload=self._batch_payload([(200, success_payload), (202, error_payload)])) + msg = messaging.MulticastMessage(tokens=['foo', 'foo']) + with pytest.raises(messaging.ApiCallError) as excinfo: + messaging.send_multicast(msg, dry_run=True) + assert str(excinfo.value) == 'test error' + assert str(excinfo.value.code) == 'registration-token-not-registered' + @pytest.mark.parametrize('status', HTTP_ERRORS) def test_send_multicast_detailed_error(self, status): success_payload = json.dumps({'name': 'message-id'}) From ecec7fabb2aa743f5dc9d7caefb5632462d61755 Mon Sep 17 00:00:00 2001 From: Zachary Orr Date: Fri, 3 May 2019 22:26:18 -0400 Subject: [PATCH 6/8] Fix CI --- firebase_admin/messaging.py | 1 + tests/test_messaging.py | 13 ++++++------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index 2c854446e..f2ba46733 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -453,6 +453,7 @@ def _message_data(self, message, dry_run): return data def _postproc(self, resp, body): + """Handle response from batch API request.""" if resp.status is not 200: data = {} try: diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 1bfa842f9..bbb9986da 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -55,13 +55,13 @@ def test_invalid_tokens_type(self, tokens): def test_tokens_over_one_hundred(self): with pytest.raises(ValueError) as excinfo: - messaging.MulticastMessage(tokens=['token' for i in xrange(0, 101)]) + messaging.MulticastMessage(tokens=['token' for _ in range(0, 101)]) expected = 'MulticastMessage.tokens must not contain more than 100 tokens.' assert str(excinfo.value) == expected def test_tokens_type(self): messaging.MulticastMessage(tokens=['token']) - messaging.MulticastMessage(tokens=['token' for i in xrange(0, 100)]) + messaging.MulticastMessage(tokens=['token' for _ in range(0, 100)]) class TestMessageEncoder(object): @@ -1401,7 +1401,7 @@ def test_invalid_send_all(self, msg): def test_invalid_over_one_hundred(self): msg = messaging.Message(topic='foo') with pytest.raises(ValueError) as excinfo: - messaging.send_all([msg for i in xrange(0, 101)]) + messaging.send_all([msg for _ in range(0, 101)]) expected = 'send_all messages must not contain more than 100 messages.' assert str(excinfo.value) == expected @@ -1441,7 +1441,7 @@ def test_send_all_non_error_non_200_detailed_error(self): payload=self._batch_payload([(200, success_payload), (202, error_payload)])) msg = messaging.Message(topic='foo') with pytest.raises(messaging.ApiCallError) as excinfo: - batch_response = messaging.send_all([msg, msg], dry_run=True) + messaging.send_all([msg, msg], dry_run=True) assert str(excinfo.value) == 'test error' assert str(excinfo.value.code) == 'invalid-argument' @@ -1458,7 +1458,6 @@ def test_send_all_non_error_non_200_canonical_error_code(self): msg = messaging.Message(topic='foo') with pytest.raises(messaging.ApiCallError) as excinfo: messaging.send_all([msg, msg], dry_run=True) - expected = 'send_all messages must not contain more than 100 messages.' assert str(excinfo.value) == 'test error' assert str(excinfo.value.code) == 'registration-token-not-registered' @@ -1689,7 +1688,7 @@ def test_send_multicast_non_error_non_200_detailed_error(self): payload=self._batch_payload([(200, success_payload), (202, error_payload)])) msg = messaging.MulticastMessage(tokens=['foo', 'foo']) with pytest.raises(messaging.ApiCallError) as excinfo: - batch_response = messaging.send_multicast(msg, dry_run=True) + messaging.send_multicast(msg, dry_run=True) assert str(excinfo.value) == 'test error' assert str(excinfo.value.code) == 'invalid-argument' @@ -1788,7 +1787,7 @@ def test_send_multicast_canonical_error_code(self, status): assert str(exception.code) == 'registration-token-not-registered' @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_send_multicast_canonical_error_code(self, status): + def test_send_multicast_fcm_error_code(self, status): success_payload = json.dumps({'name': 'message-id'}) error_payload = json.dumps({ 'error': { From 1206a7b0bcbe697e02bf1571523c6af1f57025e4 Mon Sep 17 00:00:00 2001 From: ZachOrr Date: Sun, 12 May 2019 12:06:13 -0400 Subject: [PATCH 7/8] Update postproc, update tests --- firebase_admin/messaging.py | 11 +-- tests/test_messaging.py | 167 ++++-------------------------------- 2 files changed, 19 insertions(+), 159 deletions(-) diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index f2ba46733..971dd0662 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -454,16 +454,7 @@ def _message_data(self, message, dry_run): def _postproc(self, resp, body): """Handle response from batch API request.""" - if resp.status is not 200: - data = {} - try: - parsed_body = json.loads(body.decode()) - if isinstance(parsed_body, dict): - data = parsed_body - except ValueError: - pass - code, msg = _MessagingService._parse_fcm_error(data, body, resp.status) - raise ApiCallError(code, msg) + # This only gets called for 2xx responses. return json.loads(body.decode()) def _handle_fcm_error(self, error): diff --git a/tests/test_messaging.py b/tests/test_messaging.py index bbb9986da..de940b591 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -1418,71 +1418,6 @@ def test_send_all(self): assert all([r.success for r in batch_response.responses]) assert not any([r.exception for r in batch_response.responses]) - def test_send_all_non_error_non_200(self): - success_payload = json.dumps({'name': 'message-id'}) - error_payload = json.dumps({}) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, success_payload), (202, error_payload)])) - msg = messaging.Message(topic='foo') - with pytest.raises(messaging.ApiCallError) as excinfo: - messaging.send_all([msg, msg], dry_run=True) - assert str(excinfo.value) == 'Unexpected HTTP response with status: 202; body: {}\r\n' - assert str(excinfo.value.code) == messaging._MessagingService.UNKNOWN_ERROR - - def test_send_all_non_error_non_200_detailed_error(self): - success_payload = json.dumps({'name': 'message-id'}) - error_payload = json.dumps({ - 'error': { - 'status': 'INVALID_ARGUMENT', - 'message': 'test error' - } - }) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, success_payload), (202, error_payload)])) - msg = messaging.Message(topic='foo') - with pytest.raises(messaging.ApiCallError) as excinfo: - messaging.send_all([msg, msg], dry_run=True) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'invalid-argument' - - def test_send_all_non_error_non_200_canonical_error_code(self): - success_payload = json.dumps({'name': 'message-id'}) - error_payload = json.dumps({ - 'error': { - 'status': 'NOT_FOUND', - 'message': 'test error' - } - }) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, success_payload), (202, error_payload)])) - msg = messaging.Message(topic='foo') - with pytest.raises(messaging.ApiCallError) as excinfo: - messaging.send_all([msg, msg], dry_run=True) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'registration-token-not-registered' - - def test_send_all_non_error_non_200_fcm_error_code(self): - success_payload = json.dumps({'name': 'message-id'}) - error_payload = json.dumps({ - 'error': { - 'status': 'INVALID_ARGUMENT', - 'message': 'test error', - 'details': [ - { - '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', - 'errorCode': 'UNREGISTERED', - }, - ], - } - }) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, success_payload), (202, error_payload)])) - msg = messaging.Message(topic='foo') - with pytest.raises(messaging.ApiCallError) as excinfo: - messaging.send_all([msg, msg], dry_run=True) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'registration-token-not-registered' - @pytest.mark.parametrize('status', HTTP_ERRORS) def test_send_all_detailed_error(self, status): success_payload = json.dumps({'name': 'message-id'}) @@ -1501,12 +1436,12 @@ def test_send_all_detailed_error(self, status): assert len(batch_response.responses) == 2 success_response = batch_response.responses[0] assert success_response.message_id == 'message-id' - assert success_response.success + assert success_response.success is True assert success_response.exception is None error_response = batch_response.responses[1] assert error_response.message_id is None - assert not error_response.success - assert error_response.exception + assert error_response.success is False + assert error_response.exception is not None exception = error_response.exception assert str(exception) == 'test error' assert str(exception.code) == 'invalid-argument' @@ -1529,12 +1464,12 @@ def test_send_all_canonical_error_code(self, status): assert len(batch_response.responses) == 2 success_response = batch_response.responses[0] assert success_response.message_id == 'message-id' - assert success_response.success + assert success_response.success is True assert success_response.exception is None error_response = batch_response.responses[1] assert error_response.message_id is None - assert not error_response.success - assert error_response.exception + assert error_response.success is False + assert error_response.exception is not None exception = error_response.exception assert str(exception) == 'test error' assert str(exception.code) == 'registration-token-not-registered' @@ -1563,12 +1498,12 @@ def test_send_all_fcm_error_code(self, status): assert len(batch_response.responses) == 2 success_response = batch_response.responses[0] assert success_response.message_id == 'message-id' - assert success_response.success + assert success_response.success is True assert success_response.exception is None error_response = batch_response.responses[1] assert error_response.message_id is None - assert not error_response.success - assert error_response.exception + assert error_response.success is False + assert error_response.exception is not None exception = error_response.exception assert str(exception) == 'test error' assert str(exception.code) == 'registration-token-not-registered' @@ -1664,72 +1599,6 @@ def test_send_multicast(self): assert all([r.success for r in batch_response.responses]) assert not any([r.exception for r in batch_response.responses]) - def test_send_multicast_non_error_non_200(self): - success_payload = json.dumps({'name': 'message-id'}) - error_payload = json.dumps({}) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, success_payload), (202, error_payload)])) - msg = messaging.MulticastMessage(tokens=['foo', 'foo']) - with pytest.raises(messaging.ApiCallError) as excinfo: - messaging.send_multicast(msg, dry_run=True) - expected = 'Unexpected HTTP response with status: 202; body: {}\r\n' - assert str(excinfo.value) == expected - assert str(excinfo.value.code) == messaging._MessagingService.UNKNOWN_ERROR - - def test_send_multicast_non_error_non_200_detailed_error(self): - success_payload = json.dumps({'name': 'message-id'}) - error_payload = json.dumps({ - 'error': { - 'status': 'INVALID_ARGUMENT', - 'message': 'test error' - } - }) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, success_payload), (202, error_payload)])) - msg = messaging.MulticastMessage(tokens=['foo', 'foo']) - with pytest.raises(messaging.ApiCallError) as excinfo: - messaging.send_multicast(msg, dry_run=True) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'invalid-argument' - - def test_send_multicast_non_error_non_200_canonical_error_code(self): - success_payload = json.dumps({'name': 'message-id'}) - error_payload = json.dumps({ - 'error': { - 'status': 'NOT_FOUND', - 'message': 'test error' - } - }) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, success_payload), (202, error_payload)])) - msg = messaging.MulticastMessage(tokens=['foo', 'foo']) - with pytest.raises(messaging.ApiCallError) as excinfo: - messaging.send_multicast(msg, dry_run=True) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'registration-token-not-registered' - - def test_send_multicast_non_error_non_200_fcm_error_code(self): - success_payload = json.dumps({'name': 'message-id'}) - error_payload = json.dumps({ - 'error': { - 'status': 'INVALID_ARGUMENT', - 'message': 'test error', - 'details': [ - { - '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', - 'errorCode': 'UNREGISTERED', - }, - ], - } - }) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, success_payload), (202, error_payload)])) - msg = messaging.MulticastMessage(tokens=['foo', 'foo']) - with pytest.raises(messaging.ApiCallError) as excinfo: - messaging.send_multicast(msg, dry_run=True) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'registration-token-not-registered' - @pytest.mark.parametrize('status', HTTP_ERRORS) def test_send_multicast_detailed_error(self, status): success_payload = json.dumps({'name': 'message-id'}) @@ -1748,12 +1617,12 @@ def test_send_multicast_detailed_error(self, status): assert len(batch_response.responses) == 2 success_response = batch_response.responses[0] assert success_response.message_id == 'message-id' - assert success_response.success + assert success_response.success is True assert success_response.exception is None error_response = batch_response.responses[1] assert error_response.message_id is None - assert not error_response.success - assert error_response.exception + assert error_response.success is False + assert error_response.exception is not None exception = error_response.exception assert str(exception) == 'test error' assert str(exception.code) == 'invalid-argument' @@ -1776,12 +1645,12 @@ def test_send_multicast_canonical_error_code(self, status): assert len(batch_response.responses) == 2 success_response = batch_response.responses[0] assert success_response.message_id == 'message-id' - assert success_response.success + assert success_response.success is True assert success_response.exception is None error_response = batch_response.responses[1] assert error_response.message_id is None - assert not error_response.success - assert error_response.exception + assert error_response.success is False + assert error_response.exception is not None exception = error_response.exception assert str(exception) == 'test error' assert str(exception.code) == 'registration-token-not-registered' @@ -1810,12 +1679,12 @@ def test_send_multicast_fcm_error_code(self, status): assert len(batch_response.responses) == 2 success_response = batch_response.responses[0] assert success_response.message_id == 'message-id' - assert success_response.success + assert success_response.success is True assert success_response.exception is None error_response = batch_response.responses[1] assert error_response.message_id is None - assert not error_response.success - assert error_response.exception + assert error_response.success is False + assert error_response.exception is not None exception = error_response.exception assert str(exception) == 'test error' assert str(exception.code) == 'registration-token-not-registered' From e1b8a9c8131766fa96eb6189c478cb5a02159d9b Mon Sep 17 00:00:00 2001 From: ZachOrr Date: Mon, 13 May 2019 17:52:42 -0400 Subject: [PATCH 8/8] Fix linter errors --- firebase_admin/messaging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index 971dd0662..8129f8de1 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -452,7 +452,7 @@ def _message_data(self, message, dry_run): data['validate_only'] = True return data - def _postproc(self, resp, body): + def _postproc(self, _, body): """Handle response from batch API request.""" # This only gets called for 2xx responses. return json.loads(body.decode())