From 24565a6cca1c16f76c2a5f4da64fe8045f626496 Mon Sep 17 00:00:00 2001 From: Rakan Alhneiti Date: Thu, 5 Jan 2017 11:41:33 +0100 Subject: [PATCH 1/3] Python 3 support --- .travis.yml | 5 + pushy/admin.py | 3 +- pushy/contrib/rest_api/urls.py | 11 +- pushy/contrib/rest_api/views.py | 3 +- pushy/dispatchers.py | 309 ++++++++++------------- pushy/exceptions.py | 12 +- pushy/{tasks/__init__.py => tasks.py} | 50 ++-- pushy/utils.py | 6 +- requirements.txt | 3 +- setup.py | 6 +- tests/conftest.py | 3 +- tests/data.py | 27 ++ tests/test_dispatchers.py | 339 ++++++++++++++++---------- tests/test_models.py | 18 ++ tests/test_tasks.py | 131 ++++++++-- 15 files changed, 558 insertions(+), 368 deletions(-) rename pushy/{tasks/__init__.py => tasks.py} (81%) create mode 100644 tests/data.py create mode 100644 tests/test_models.py diff --git a/.travis.yml b/.travis.yml index e926a53..111d36c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,11 +2,16 @@ language: python python: - 2.7 + - 3.4 + - 3.5 + - 3.6 env: - DJANGO=1.7.11 - DJANGO=1.8.9 - DJANGO=1.9.2 + - DJANGO=1.10.7 + - DJANGO=1.11.4 install: - pip install -q Django==$DJANGO diff --git a/pushy/admin.py b/pushy/admin.py index f98fb4e..e9d31ae 100644 --- a/pushy/admin.py +++ b/pushy/admin.py @@ -1,7 +1,8 @@ import json from django.contrib import admin from django import forms -from models import PushNotification, Device + +from .models import PushNotification, Device class PushNotificationForm(forms.ModelForm): diff --git a/pushy/contrib/rest_api/urls.py b/pushy/contrib/rest_api/urls.py index 346da85..145ed74 100644 --- a/pushy/contrib/rest_api/urls.py +++ b/pushy/contrib/rest_api/urls.py @@ -1,13 +1,12 @@ -from django.conf.urls import patterns, url +from django.conf.urls import url -import views +from .views import DeviceViewSet -urlpatterns = patterns( - '', +urlpatterns = [ url(r'^pushy/device/$', - views.DeviceViewSet.as_view({ + DeviceViewSet.as_view({ 'post': 'create', 'delete': 'destroy' }), name='pushy-devices'), -) +] diff --git a/pushy/contrib/rest_api/views.py b/pushy/contrib/rest_api/views.py index bc35f5b..6de8a80 100644 --- a/pushy/contrib/rest_api/views.py +++ b/pushy/contrib/rest_api/views.py @@ -2,9 +2,10 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.settings import api_settings + from pushy.models import Device -from serializers import DeviceSerializer +from .serializers import DeviceSerializer class DeviceViewSet(viewsets.ViewSet): diff --git a/pushy/dispatchers.py b/pushy/dispatchers.py index 08fca9c..bc0d71c 100644 --- a/pushy/dispatchers.py +++ b/pushy/dispatchers.py @@ -1,230 +1,169 @@ -import random -import apns -from threading import Event - -from gcm import GCM -from gcm.gcm import ( - GCMNotRegisteredException, - GCMMismatchSenderIdException, - GCMException -) - from django.conf import settings +from pushjack import ( + APNSClient, + APNSSandboxClient, + GCMClient +) +from pushjack.exceptions import ( + GCMAuthError, + GCMMissingRegistrationError, + GCMInvalidRegistrationError, + GCMUnregisteredDeviceError, + GCMInvalidPackageNameError, + GCMMismatchedSenderError, + GCMMessageTooBigError, + GCMInvalidDataKeyError, + GCMInvalidTimeToLiveError, + GCMTimeoutError, + GCMInternalServerError, + GCMDeviceMessageRateExceededError, + + APNSAuthError, + APNSProcessingError, + APNSMissingTokenError, + APNSMissingTopicError, + APNSMissingPayloadError, + APNSInvalidTokenSizeError, + APNSInvalidTopicSizeError, + APNSInvalidPayloadSizeError, + APNSInvalidTokenError, + APNSShutdownError, + APNSUnknownError +) -from models import Device -from exceptions import ( - PushException, - PushGCMApiKeyException, - PushAPNsCertificateException +from .models import Device +from .exceptions import ( + PushAuthException, + PushInvalidTokenException, + PushInvalidDataException, + PushServerException ) dispatchers_cache = {} class Dispatcher(object): - PUSH_RESULT_SENT = 1 - PUSH_RESULT_NOT_REGISTERED = 2 - PUSH_RESULT_EXCEPTION = 3 - - def send(self, device_key, data): # noqa + def send(self, device_key, data): raise NotImplementedError() class APNSDispatcher(Dispatcher): - - connection = None - - error_response_events = None - - STATUS_CODE_NO_ERROR = 0 - - STATUS_CODE_PROCESSING_ERROR = 1 - - STATUS_CODE_MISSING_DEVICE_TOKEN = 2 - - STATUS_CODE_MISSING_TOPIC = 3 - - STATUS_CODE_MISSING_PAYLOAD = 4 - - STATUS_CODE_INVALID_TOKEN_SIZE = 5 - - STATUS_CODE_INVALID_TOPIC_SIZE = 6 - - STATUS_CODE_INVALID_PAYLOAD_SIZE = 7 - - STATUS_CODE_INVALID_TOKEN = 8 - - STATUS_CODE_SHUTDOWN = 10 - - STATUS_CODE_UNKNOWN = 255 - - class ErrorResponseEvent(object): - - _status = 0 - - _event = None - - def __init__(self): - self._event = Event() - - def set_status(self, value): - self._status = value - - self._event.set() - - def wait_for_response(self, timeout): - self._event.wait(timeout) - - return self._status - def __init__(self): super(APNSDispatcher, self).__init__() - - self.error_response_events = {} + self._client = None @property def cert_file(self): return getattr(settings, 'PUSHY_APNS_CERTIFICATE_FILE', None) - @property - def key_file(self): - return getattr(settings, 'PUSHY_APNS_KEY_FILE', None) - @property def use_sandbox(self): return bool(getattr(settings, 'PUSHY_APNS_SANDBOX', False)) - def on_error_response(self, error_response): - status = error_response['status'] - identifier = error_response['identifier'] - - event_object = self.error_response_events.pop(identifier, None) - - if event_object: - event_object.set_status(status) - def establish_connection(self): if self.cert_file is None: - raise PushAPNsCertificateException + raise PushAuthException('Missing APNS certificate error') - connection = apns.APNs( - use_sandbox=self.use_sandbox, - cert_file=self.cert_file, - key_file=self.key_file, - enhanced=True - ) + target_class = APNSClient + if self.use_sandbox: + target_class = APNSSandboxClient - connection.gateway_server.register_response_listener( - self.on_error_response + self._client = target_class( + certificate=self.cert_file, + default_error_timeout=10, + default_expiration_offset=2592000, + default_batch_size=100 ) - self.connection = connection - - @staticmethod - def create_identifier(): - return random.getrandbits(32) - - def _send_notification(self, token, payload): - identifier = self.create_identifier() - - event_object = self.ErrorResponseEvent() - - self.error_response_events[identifier] = event_object - - self.connection.gateway_server.send_notification( - token, payload, identifier=identifier - ) - - return event_object - - def send(self, device_key, data): - if not self.connection: + def _send(self, token, payload): + try: + response = self._client.send( + [token], + alert=payload.pop('alert', None), + sound=payload.pop('sound', None), + badge=payload.pop('badge', None), + category=payload.pop('category', None), + content_available=True, + extra=payload or {} + ) + + if response.errors: + raise response.errors.pop() + return None + + except APNSAuthError: + raise PushAuthException() + + except (APNSMissingTokenError, + APNSInvalidTokenError): + raise PushInvalidTokenException() + + except (APNSProcessingError, + APNSMissingTopicError, + APNSMissingPayloadError, + APNSInvalidTokenSizeError, + APNSInvalidTopicSizeError, + APNSInvalidPayloadSizeError): + raise PushInvalidDataException() + + except (APNSShutdownError, + APNSUnknownError): + raise PushServerException() + + def send(self, device_key, payload): + if not self._client: self.establish_connection() - payload = apns.Payload( - alert=data.pop('alert', None), - sound=data.pop('sound', None), - badge=data.pop('badge', None), - category=data.pop('category', None), - content_available=bool(data.pop('content-available', False)), - custom=data or {} - ) - - event = self._send_notification(device_key, payload) - - status = event.wait_for_response(1.5) - - if status in (self.STATUS_CODE_INVALID_TOKEN, - self.STATUS_CODE_INVALID_TOKEN_SIZE): - push_result = self.PUSH_RESULT_NOT_REGISTERED - elif status == self.STATUS_CODE_NO_ERROR: - push_result = self.PUSH_RESULT_SENT - else: - push_result = self.PUSH_RESULT_EXCEPTION - - return push_result, 0 + return self._send(device_key, payload) class GCMDispatcher(Dispatcher): + def __init__(self, api_key=None): + if not api_key: + api_key = getattr(settings, 'PUSHY_GCM_API_KEY', None) + self._api_key = api_key - def _send_plaintext(self, gcm_client, device_key, data): - return gcm_client.plaintext_request(device_key, data=data) - - def _send_json(self, gcm_client, device_key, data): - response = gcm_client.json_request(registration_ids=[device_key], - data=data) + def _send(self, device_key, payload): + if not self._api_key: + raise PushAuthException() - device_error = None - - if 'errors' in response: - for error, reg_ids in response['errors'].items(): - # Check for errors and act accordingly - - if device_key in reg_ids: - device_error = error - break - - if device_error: - gcm_client.raise_error(device_error) - - raise GCMException(device_error) + gcm_client = GCMClient(self._api_key) + try: + response = gcm_client.send( + [device_key], + payload + ) - if 'canonical' in response: - canonical_id = response['canonical'].get(device_key, 0) - else: - canonical_id = 0 + if response.errors: + raise response.errors.pop() - return canonical_id + canonical_id = None + if response.canonical_ids: + canonical_id = response.canonical_ids[0].new_id + return canonical_id - def send(self, device_key, data): - gcm_api_key = getattr(settings, 'PUSHY_GCM_API_KEY', None) + except GCMAuthError: + raise PushAuthException() - gcm_json_payload = getattr(settings, 'PUSHY_GCM_JSON_PAYLOAD', True) + except (GCMMissingRegistrationError, + GCMInvalidRegistrationError, + GCMUnregisteredDeviceError): + raise PushInvalidTokenException() - if not gcm_api_key: - raise PushGCMApiKeyException() + except (GCMInvalidPackageNameError, + GCMMismatchedSenderError, + GCMMessageTooBigError, + GCMInvalidDataKeyError, + GCMInvalidTimeToLiveError): + raise PushInvalidDataException() - gcm = GCM(gcm_api_key) + except (GCMTimeoutError, + GCMInternalServerError, + GCMDeviceMessageRateExceededError): + raise PushServerException() - # Plaintext request - try: - if gcm_json_payload: - canonical_id = self._send_json(gcm, device_key, data) - else: - canonical_id = self._send_plaintext(gcm, device_key, data) - - if canonical_id: - return self.PUSH_RESULT_SENT, canonical_id - else: - return self.PUSH_RESULT_SENT, 0 - except GCMNotRegisteredException: - return self.PUSH_RESULT_NOT_REGISTERED, 0 - except GCMMismatchSenderIdException: - return self.PUSH_RESULT_EXCEPTION, 0 - except IOError: - return self.PUSH_RESULT_EXCEPTION, 0 - except PushException: - return self.PUSH_RESULT_EXCEPTION, 0 + def send(self, device_key, payload): + return self._send(device_key, payload) def get_dispatcher(device_type): @@ -233,7 +172,7 @@ def get_dispatcher(device_type): if device_type == Device.DEVICE_TYPE_ANDROID: dispatchers_cache[device_type] = GCMDispatcher() - elif device_type == Device.DEVICE_TYPE_IOS: + else: dispatchers_cache[device_type] = APNSDispatcher() return dispatchers_cache[device_type] diff --git a/pushy/exceptions.py b/pushy/exceptions.py index b64bb97..346f289 100644 --- a/pushy/exceptions.py +++ b/pushy/exceptions.py @@ -2,9 +2,17 @@ class PushException(Exception): pass -class PushGCMApiKeyException(Exception): +class PushAuthException(PushException): pass -class PushAPNsCertificateException(Exception): +class PushInvalidTokenException(PushException): + pass + + +class PushInvalidDataException(PushException): + pass + + +class PushServerException(PushException): pass diff --git a/pushy/tasks/__init__.py b/pushy/tasks.py similarity index 81% rename from pushy/tasks/__init__.py rename to pushy/tasks.py index 5ce6b9c..9ebc910 100644 --- a/pushy/tasks/__init__.py +++ b/pushy/tasks.py @@ -1,13 +1,26 @@ import datetime +import logging + import celery from django.conf import settings from django.db import transaction from django.db.utils import IntegrityError from django.utils import timezone -from ..models import PushNotification -from ..dispatchers import get_dispatcher, Dispatcher -from ..models import get_filtered_devices_queryset, Device + +from .models import ( + PushNotification, + Device, + get_filtered_devices_queryset +) +from .exceptions import ( + PushInvalidTokenException, + PushException +) +from .dispatchers import get_dispatcher + + +logger = logging.getLogger(__name__) @celery.shared_task( @@ -87,20 +100,25 @@ def send_single_push_notification(device, payload): dispatcher = get_dispatcher(device.type) - result, canonical_id = dispatcher.send(device.key, payload) - - if result == Dispatcher.PUSH_RESULT_SENT: - if canonical_id > 0: - try: - with transaction.atomic(): - device.key = canonical_id - device.save() - except IntegrityError: - device.delete() - elif result == Dispatcher.PUSH_RESULT_NOT_REGISTERED: - device.delete() + try: + canonical_id = dispatcher.send(device.key, payload) + if not canonical_id: + return - return True + with transaction.atomic(): + device.key = canonical_id + device.save() + + except IntegrityError: + device.delete() + except PushInvalidTokenException: + logger.debug('Token for device {} does not exist, skipping'.format( + device.id + )) + device.delete() + except PushException: + logger.exception("An error occured while sending push notification") + return @celery.shared_task( diff --git a/pushy/utils.py b/pushy/utils.py index 98dafff..a556a0b 100644 --- a/pushy/utils.py +++ b/pushy/utils.py @@ -1,6 +1,6 @@ -from models import PushNotification -from pushy.tasks import send_single_push_notification -from tasks import create_push_notification_groups +from .models import PushNotification +from .tasks import send_single_push_notification +from .tasks import create_push_notification_groups def send_push_notification(title, payload, device=None, diff --git a/requirements.txt b/requirements.txt index 1395012..e8f9775 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ Django django-celery==3.1.17 -python-gcm<0.4 -apns==2.0.1 +pushjack==1.3.0 djangorestframework>=3.0,<3.3 diff --git a/setup.py b/setup.py index 0737066..da302bd 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='Django-Pushy', - version='0.1.13', + version='1.0.0', author='Rakan Alhneiti', author_email='rakan.alhneiti@gmail.com', @@ -11,7 +11,6 @@ 'pushy', 'pushy/contrib', 'pushy/contrib/rest_api', - 'pushy/tasks', 'pushy/migrations', ], include_package_data=True, @@ -26,9 +25,8 @@ # Dependent packages (distributions) install_requires=[ 'django>=1.6', - 'python-gcm==0.4', 'django-celery==3.1.17', - 'apns==2.0.1' + 'pushjack==1.3.0' ], extras_require={ 'rest_api': ['djangorestframework>=3.0,<3.3'] diff --git a/tests/conftest.py b/tests/conftest.py index deae062..0fc0b77 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,5 +26,6 @@ def pytest_configure(): PUSHY_GCM_API_KEY='SOME_TEST_KEY', PUSHY_GCM_JSON_PAYLOAD=False, - PUSHY_APNS_CERTIFICATE_FILE='/var/apns/certificate' + PUSHY_APNS_CERTIFICATE_FILE='/var/apns/certificate', + PUSHY_APNS_SANDBOX=False ) diff --git a/tests/data.py b/tests/data.py new file mode 100644 index 0000000..92ec1d6 --- /dev/null +++ b/tests/data.py @@ -0,0 +1,27 @@ +from pushjack import ( + GCMCanonicalID +) + + +class ResponseMock: + def __init__(self, status_code): + self.status_code = status_code + self.errors = [] + self.canonical_ids = [] + + +def valid_response(): + return ResponseMock(200) + + +def valid_with_canonical_id_response(canonical_id): + canonical_id_obj = GCMCanonicalID(canonical_id, canonical_id) + response = ResponseMock(200) + response.canonical_ids = [canonical_id_obj] + return response + + +def invalid_with_exception(exc): + response = ResponseMock(400) + response.errors.append(exc) + return response diff --git a/tests/test_dispatchers.py b/tests/test_dispatchers.py index c300471..50e611a 100644 --- a/tests/test_dispatchers.py +++ b/tests/test_dispatchers.py @@ -1,14 +1,35 @@ -from django.test.utils import override_settings import mock -from gcm.gcm import GCMNotRegisteredException from django.test import TestCase -from pushy.exceptions import PushException, PushGCMApiKeyException, PushAPNsCertificateException +from pushjack.apns import APNSSandboxClient +from pushjack.exceptions import ( + GCMMissingRegistrationError, + GCMInvalidPackageNameError, + GCMTimeoutError, + GCMAuthError, + APNSAuthError, + APNSMissingTokenError, + APNSProcessingError, + APNSShutdownError +) + +from pushy.exceptions import ( + PushInvalidTokenException, + PushInvalidDataException, + PushAuthException, + PushServerException +) from pushy.models import Device from pushy import dispatchers +from .data import ( + valid_response, + valid_with_canonical_id_response, + invalid_with_exception +) + class DispatchersTestCase(TestCase): @@ -21,153 +42,211 @@ def test_check_cache(self): # Test cache iOS dispatcher2 = dispatchers.get_dispatcher(Device.DEVICE_TYPE_IOS) - self.assertEquals(dispatchers.dispatchers_cache, {1: dispatcher1, 2: dispatcher2}) + self.assertEquals( + dispatchers.dispatchers_cache, + {1: dispatcher1, 2: dispatcher2} + ) # Final check, fetching from cache dispatcher1 = dispatchers.get_dispatcher(Device.DEVICE_TYPE_ANDROID) - self.assertEquals(dispatchers.dispatchers_cache, {1: dispatcher1, 2: dispatcher2}) + dispatcher2 = dispatchers.get_dispatcher(Device.DEVICE_TYPE_IOS) + self.assertEquals( + dispatchers.dispatchers_cache, + {1: dispatcher1, 2: dispatcher2} + ) def test_dispatcher_types(self): # Double check the factory method returning the correct types - self.assertIsInstance(dispatchers.get_dispatcher(Device.DEVICE_TYPE_ANDROID), dispatchers.GCMDispatcher) - self.assertIsInstance(dispatchers.get_dispatcher(Device.DEVICE_TYPE_IOS), dispatchers.APNSDispatcher) + self.assertIsInstance( + dispatchers.get_dispatcher(Device.DEVICE_TYPE_ANDROID), + dispatchers.GCMDispatcher + ) + self.assertIsInstance( + dispatchers.get_dispatcher(Device.DEVICE_TYPE_IOS), + dispatchers.APNSDispatcher + ) - def test_dispatcher_android(self): - android = dispatchers.get_dispatcher(Device.DEVICE_TYPE_ANDROID) - device_key = 'TEST_DEVICE_KEY' - data = {'title': 'Test', 'body': 'Test body'} - - # Check that we throw the proper exception in case no API Key is specified - with mock.patch('django.conf.settings.PUSHY_GCM_API_KEY', new=None): - self.assertRaises(PushGCMApiKeyException, android.send, device_key, data) - - with mock.patch('django.conf.settings.PUSHY_GCM_JSON_PAYLOAD', new=True): - with mock.patch('gcm.GCM.json_request') as json_request_mock: - android.send(device_key, data) +class GCMDispatcherTestCase(TestCase): + device_key = 'TEST_DEVICE_KEY' + data = {'title': 'Test', 'body': 'Test body'} - self.assertTrue(json_request_mock.called) + def test_constructor_with_api_key(self): + dispatcher = dispatchers.GCMDispatcher(123) + self.assertEquals(123, dispatcher._api_key) + def test_send_with_no_api_key(self): + # Check that we throw the proper exception + # in case no API Key is specified + with mock.patch('django.conf.settings.PUSHY_GCM_API_KEY', new=None): + dispatcher = dispatchers.GCMDispatcher() + self.assertRaises( + PushAuthException, + dispatcher.send, + self.device_key, + self.data + ) + + def test_notification_sent(self): + dispatcher = dispatchers.GCMDispatcher() + with mock.patch('pushjack.GCMClient.send') as request_mock: + request_mock.return_value = valid_response() + dispatcher.send(self.device_key, self.data) + self.assertTrue(request_mock.called) + + def test_notification_sent_with_canonical_id(self): + dispatcher = dispatchers.GCMDispatcher() # Check result when canonical value is returned - gcm = mock.Mock() - gcm.return_value = 123123 - with mock.patch('gcm.GCM.plaintext_request', new=gcm): - result, canonical_id = android.send(device_key, data) - - self.assertEquals(result, dispatchers.GCMDispatcher.PUSH_RESULT_SENT) + response_mock = mock.Mock() + response_mock.return_value = valid_with_canonical_id_response(123123) + with mock.patch('pushjack.GCMClient.send', new=response_mock): + canonical_id = dispatcher.send(self.device_key, self.data) self.assertEquals(canonical_id, 123123) + def test_invalid_token_exception(self): + dispatcher = dispatchers.GCMDispatcher() # Check not registered exception - gcm = mock.Mock(side_effect=GCMNotRegisteredException) - with mock.patch('gcm.GCM.plaintext_request', new=gcm): - result, canonical_id = android.send(device_key, data) - - self.assertEquals(result, dispatchers.GCMDispatcher.PUSH_RESULT_NOT_REGISTERED) - self.assertEquals(canonical_id, 0) - - # Check IOError - gcm = mock.Mock(side_effect=IOError) - with mock.patch('gcm.GCM.plaintext_request', new=gcm): - result, canonical_id = android.send(device_key, data) - - self.assertEquals(result, dispatchers.GCMDispatcher.PUSH_RESULT_EXCEPTION) - self.assertEquals(canonical_id, 0) - - # Check all other exceptions - gcm = mock.Mock(side_effect=PushException) - with mock.patch('gcm.GCM.plaintext_request', new=gcm): - result, canonical_id = android.send(device_key, data) - - self.assertEquals(result, dispatchers.GCMDispatcher.PUSH_RESULT_EXCEPTION) - self.assertEquals(canonical_id, 0) - - @mock.patch('gcm.GCM.json_request') - def test__send_json(self, json_request_mock): - android = dispatchers.get_dispatcher(Device.DEVICE_TYPE_ANDROID) - - assert isinstance(android, dispatchers.GCMDispatcher) - - api_key = 'TEST_API_KEY' - device_key = 'TEST_DEVICE_KEY' - data = {'title': 'Test', 'body': 'Test body'} - - gcm_client = dispatchers.GCM(api_key) - - # Test canonical not update - json_request_mock.return_value = {} - self.assertEqual(android._send_json(gcm_client, device_key, data), 0) - - # Test canonical updated - canonical_id = 'TEST_CANONICAL' - json_request_mock.return_value = { - 'canonical': { - device_key: canonical_id - } - } - self.assertEqual(android._send_json(gcm_client, device_key, data), canonical_id) - - # Test Missing Registration - json_request_mock.return_value = { - 'errors': { - 'NotRegistered': [device_key] - } - } - self.assertRaises(dispatchers.GCMNotRegisteredException, - android._send_json, gcm_client, device_key, data) - - # Test handling unexpected (server) errors - - json_request_mock.return_value = { - 'errors': { - 'InternalServerError': [device_key] - } - } - self.assertRaises(dispatchers.GCMException, - android._send_json, gcm_client, device_key, data) - - -@mock.patch('pushy.dispatchers.APNSDispatcher._send_notification', - new=lambda *a: dispatchers.APNSDispatcher.ErrorResponseEvent()) -class ApnsDispatcherTests(TestCase): + response_mock = mock.Mock() + response_mock.return_value = invalid_with_exception( + GCMAuthError('') + ) + with mock.patch('pushjack.GCMClient.send', new=response_mock): + self.assertRaises( + PushAuthException, + dispatcher.send, + self.device_key, + self.data + ) + + def test_invalid_api_key_exception(self): + dispatcher = dispatchers.GCMDispatcher() + # Check not registered exception + response_mock = mock.Mock() + response_mock.return_value = invalid_with_exception( + GCMMissingRegistrationError('') + ) + with mock.patch('pushjack.GCMClient.send', new=response_mock): + self.assertRaises( + PushInvalidTokenException, + dispatcher.send, + self.device_key, + self.data + ) + + def test_invalid_data_exception(self): + dispatcher = dispatchers.GCMDispatcher() + # Check not registered exception + response_mock = mock.Mock() + response_mock.return_value = invalid_with_exception( + GCMInvalidPackageNameError('') + ) + with mock.patch('pushjack.GCMClient.send', new=response_mock): + self.assertRaises( + PushInvalidDataException, + dispatcher.send, + self.device_key, + self.data + ) + + def test_invalid_exception(self): + dispatcher = dispatchers.GCMDispatcher() + # Check not registered exception + response_mock = mock.Mock() + response_mock.return_value = invalid_with_exception( + GCMTimeoutError('') + ) + with mock.patch('pushjack.GCMClient.send', new=response_mock): + self.assertRaises( + PushServerException, + dispatcher.send, + self.device_key, + self.data + ) - dispatcher = None +class ApnsDispatcherTests(TestCase): device_key = 'TEST_DEVICE_KEY' - - data = { - 'alert': 'Test' - } + data = {'alert': 'Test'} def setUp(self): self.dispatcher = dispatchers.get_dispatcher(Device.DEVICE_TYPE_IOS) @mock.patch('django.conf.settings.PUSHY_APNS_CERTIFICATE_FILE', new=None) def test_certificate_exception_on_send(self): - self.assertRaises(PushAPNsCertificateException, self.dispatcher.send, self.device_key, self.data) - - @mock.patch('pushy.dispatchers.APNSDispatcher.ErrorResponseEvent.wait_for_response') - def test_invalid_token_error_response(self, wait_for_response): - wait_for_response.return_value = dispatchers.APNSDispatcher.STATUS_CODE_INVALID_TOKEN - - self.assertEqual(self.dispatcher.send(self.device_key, self.data), - (dispatchers.Dispatcher.PUSH_RESULT_NOT_REGISTERED, 0)) - - wait_for_response.return_value = dispatchers.APNSDispatcher.STATUS_CODE_INVALID_TOKEN_SIZE - - self.assertEqual(self.dispatcher.send(self.device_key, self.data), - (dispatchers.Dispatcher.PUSH_RESULT_NOT_REGISTERED, 0)) - - - @mock.patch('pushy.dispatchers.APNSDispatcher.ErrorResponseEvent.wait_for_response') - def test_push_exception(self, wait_for_response): - wait_for_response.return_value = dispatchers.APNSDispatcher.STATUS_CODE_INVALID_PAYLOAD_SIZE - - self.assertEqual(self.dispatcher.send(self.device_key, self.data), - (dispatchers.Dispatcher.PUSH_RESULT_EXCEPTION, 0)) - - @mock.patch('pushy.dispatchers.APNSDispatcher.ErrorResponseEvent.wait_for_response') - def test_push_sent(self, wait_for_response): - wait_for_response.return_value = dispatchers.APNSDispatcher.STATUS_CODE_NO_ERROR - - self.assertEqual(self.dispatcher.send(self.device_key, self.data), - (dispatchers.Dispatcher.PUSH_RESULT_SENT, 0)) \ No newline at end of file + self.assertRaises( + PushAuthException, + self.dispatcher.send, + self.device_key, + self.data + ) + + @mock.patch('django.conf.settings.PUSHY_APNS_SANDBOX', new=True) + def test_sandbox_client(self): + dispatcher = dispatchers.APNSDispatcher() + dispatcher.establish_connection() + self.assertIsInstance(dispatcher._client, APNSSandboxClient) + + def test_invalid_token_exception(self): + # Check not registered exception + response_mock = mock.Mock() + response_mock.return_value = invalid_with_exception( + APNSMissingTokenError('') + ) + with mock.patch('pushjack.APNSClient.send', new=response_mock): + self.assertRaises( + PushInvalidTokenException, + self.dispatcher.send, + self.device_key, + self.data + ) + + def test_invalid_api_key_exception(self): + # Check not registered exception + response_mock = mock.Mock() + response_mock.return_value = invalid_with_exception( + APNSAuthError('') + ) + with mock.patch('pushjack.APNSClient.send', new=response_mock): + self.assertRaises( + PushAuthException, + self.dispatcher.send, + self.device_key, + self.data + ) + + def test_invalid_data_exception(self): + # Check not registered exception + response_mock = mock.Mock() + response_mock.return_value = invalid_with_exception( + APNSProcessingError('') + ) + with mock.patch('pushjack.APNSClient.send', new=response_mock): + self.assertRaises( + PushInvalidDataException, + self.dispatcher.send, + self.device_key, + self.data + ) + + def test_invalid_exception(self): + # Check not registered exception + response_mock = mock.Mock() + response_mock.return_value = invalid_with_exception( + APNSShutdownError('') + ) + with mock.patch('pushjack.APNSClient.send', new=response_mock): + self.assertRaises( + PushServerException, + self.dispatcher.send, + self.device_key, + self.data + ) + + def test_push_sent(self): + apns = mock.Mock() + apns.return_value = valid_response() + with mock.patch('pushjack.APNSClient.send', new=apns): + self.assertEqual( + self.dispatcher.send(self.device_key, self.data), + None + ) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..5bfc719 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,18 @@ +from django.test import TestCase + +from pushy.models import PushNotification + + +class TasksTestCase(TestCase): + def test_empty_payload(self): + notification = PushNotification() + self.assertEqual(None, notification.payload) + + def test_valid_payload(self): + payload = { + 'attr': 'value' + } + notification = PushNotification() + notification.payload = payload + + self.assertEqual(payload, notification.payload) diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 2f2dc1f..fac1ec4 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -6,20 +6,24 @@ from django.test.utils import override_settings from django.utils import timezone -from gcm.gcm import GCMNotRegisteredException - from pushy.models import ( PushNotification, Device, get_filtered_devices_queryset ) +from pushy.exceptions import ( + PushException, + PushInvalidTokenException +) + from pushy.tasks import ( check_pending_push_notifications, send_push_notification_group, send_single_push_notification, create_push_notification_groups, - clean_sent_notifications + clean_sent_notifications, + notify_push_notification_sent ) @@ -108,7 +112,9 @@ def test_pending_notifications(self): ) mocked_task = mock.Mock() - with mock.patch('pushy.tasks.create_push_notification_groups.apply_async', new=mocked_task): + with mock.patch( + 'pushy.tasks.create_push_notification_groups.apply_async', + new=mocked_task): check_pending_push_notifications() notification = PushNotification.objects.get(pk=notification.id) @@ -118,6 +124,27 @@ def test_pending_notifications(self): notification = PushNotification.objects.get(pk=notification.id) self.assertEqual(notification.sent, PushNotification.PUSH_SENT) + def test_notifications_groups_chord(self): + notification = PushNotification.objects.create( + title='test', + payload=self.payload, + active=PushNotification.PUSH_ACTIVE, + sent=PushNotification.PUSH_NOT_SENT + ) + + # Create a test device key + Device.objects.create( + key='TEST_DEVICE_KEY_ANDROID', + type=Device.DEVICE_TYPE_ANDROID + ) + + mocked_task = mock.Mock() + with mock.patch( + 'celery.chord', + new=mocked_task): + create_push_notification_groups(notification.id) + mocked_task.assert_called() + def test_send_notification_groups(self): notification = PushNotification.objects.create( title='test', @@ -130,12 +157,15 @@ def test_send_notification_groups(self): self.assertFalse(send_push_notification_group(13, 0, 1)) # Create a test device key - device = Device.objects.create(key='TEST_DEVICE_KEY_ANDROID', type=Device.DEVICE_TYPE_ANDROID) + device = Device.objects.create( + key='TEST_DEVICE_KEY_ANDROID', + type=Device.DEVICE_TYPE_ANDROID + ) # Make sure canonical ID is saved gcm = mock.Mock() gcm.return_value = 123123 - with mock.patch('gcm.GCM.plaintext_request', new=gcm): + with mock.patch('pushy.dispatchers.GCMDispatcher.send', new=gcm): send_push_notification_group(notification.id, 0, 1) device = Device.objects.get(pk=device.id) @@ -143,19 +173,26 @@ def test_send_notification_groups(self): # Make sure the key is deleted when not registered exception is fired gcm = mock.Mock() - gcm.side_effect = GCMNotRegisteredException - with mock.patch('gcm.GCM.plaintext_request', new=gcm): + gcm.side_effect = PushInvalidTokenException + with mock.patch('pushy.dispatchers.GCMDispatcher.send', new=gcm): send_push_notification_group(notification.id, 0, 1) - self.assertRaises(Device.DoesNotExist, Device.objects.get, pk=device.id) + self.assertRaises( + Device.DoesNotExist, + Device.objects.get, + pk=device.id + ) # Create an another test device key - device = Device.objects.create(key='TEST_DEVICE_KEY_ANDROID2', type=Device.DEVICE_TYPE_ANDROID) + device = Device.objects.create( + key='TEST_DEVICE_KEY_ANDROID2', + type=Device.DEVICE_TYPE_ANDROID + ) # No canonical ID wasn't returned gcm = mock.Mock() gcm.return_value = False - with mock.patch('gcm.GCM.plaintext_request', new=gcm): + with mock.patch('pushy.dispatchers.GCMDispatcher.send', new=gcm): send_push_notification_group(notification.id, 0, 1) device = Device.objects.get(pk=device.id) @@ -169,13 +206,17 @@ def test_delete_old_key_if_canonical_is_registered(self): sent=PushNotification.PUSH_NOT_SENT ) # Create a test device key - device = Device.objects.create(key='TEST_DEVICE_KEY_ANDROID', type=Device.DEVICE_TYPE_ANDROID) + device = Device.objects.create( + key='TEST_DEVICE_KEY_ANDROID', + type=Device.DEVICE_TYPE_ANDROID + ) Device.objects.create(key='123123', type=Device.DEVICE_TYPE_ANDROID) - # Make sure old device is deleted if the new canonical ID already exists + # Make sure old device is deleted + # if the new canonical ID already exists gcm = mock.Mock() gcm.return_value = '123123' - with mock.patch('gcm.GCM.plaintext_request', new=gcm): + with mock.patch('pushy.dispatchers.GCMDispatcher.send', new=gcm): send_push_notification_group(notification.id, 0, 1) self.assertFalse(Device.objects.filter(pk=device.id).exists()) @@ -188,12 +229,42 @@ def test_non_existent_device(self): result = send_single_push_notification(1000, {'payload': 'test'}) self.assertFalse(result) + def test_invalid_token_exception(self): + # Create a test device key + device = Device.objects.create( + key='TEST_DEVICE_KEY_ANDROID', + type=Device.DEVICE_TYPE_ANDROID + ) + gcm = mock.Mock() + gcm.side_effect = PushInvalidTokenException + with mock.patch('pushy.dispatchers.GCMDispatcher.send', new=gcm): + send_single_push_notification(device, {'payload': 'test'}) + self.assertRaises( + Device.DoesNotExist, + Device.objects.get, + pk=device.id + ) + + @mock.patch('pushy.tasks.logger.exception') + def test_push_exception(self, logging_mock): + # Create a test device key + device = Device.objects.create( + key='TEST_DEVICE_KEY_ANDROID', + type=Device.DEVICE_TYPE_ANDROID + ) + gcm = mock.Mock() + gcm.side_effect = PushException + with mock.patch('pushy.dispatchers.GCMDispatcher.send', new=gcm): + send_single_push_notification(device, {'payload': 'test'}) + + logging_mock.assert_called() + def test_delete_old_notifications_undefine_max_age(self): self.assertRaises(ValueError, clean_sent_notifications) @override_settings(PUSHY_NOTIFICATION_MAX_AGE=datetime.timedelta(days=90)) def test_delete_old_notifications(self): - for i in xrange(10): + for i in range(10): date_started = timezone.now() - datetime.timedelta(days=91) date_finished = date_started notification = PushNotification() @@ -210,7 +281,7 @@ def test_delete_old_notifications(self): @override_settings(PUSHY_NOTIFICATION_MAX_AGE=datetime.timedelta(days=90)) def test_delete_old_notifications_with_remaining_onces(self): - for i in xrange(10): + for i in range(10): date_started = timezone.now() - datetime.timedelta(days=91) date_finished = date_started notification = PushNotification() @@ -221,7 +292,7 @@ def test_delete_old_notifications_with_remaining_onces(self): notification.date_finished = date_finished notification.save() - for i in xrange(10): + for i in range(10): date_started = timezone.now() - datetime.timedelta(days=61) date_finished = date_started notification = PushNotification() @@ -235,3 +306,29 @@ def test_delete_old_notifications_with_remaining_onces(self): clean_sent_notifications() self.assertEquals(PushNotification.objects.count(), 10) + + def test_notify_notification_finished(self): + notification = PushNotification.objects.create( + title='test', + payload=self.payload, + active=PushNotification.PUSH_ACTIVE, + sent=PushNotification.PUSH_NOT_SENT + ) + notification.save() + notify_push_notification_sent(notification.id) + + notification = PushNotification.objects.get(pk=notification.id) + self.assertEquals(PushNotification.PUSH_SENT, notification.sent) + + def test_notify_notification_does_not_exist(self): + notification = PushNotification.objects.create( + title='test', + payload=self.payload, + active=PushNotification.PUSH_ACTIVE, + sent=PushNotification.PUSH_NOT_SENT + ) + notification.save() + notify_push_notification_sent(1000) # dummy id + + notification = PushNotification.objects.get(pk=notification.id) + self.assertEquals(PushNotification.PUSH_NOT_SENT, notification.sent) From a49b30a7dd40986ef18613d63bc7799e4682a71a Mon Sep 17 00:00:00 2001 From: Rakan Alhneiti Date: Sat, 5 Aug 2017 02:30:04 +0200 Subject: [PATCH 2/3] Update requirements --- .travis.yml | 1 - requirements.txt | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 111d36c..86916bb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,6 @@ python: - 3.6 env: - - DJANGO=1.7.11 - DJANGO=1.8.9 - DJANGO=1.9.2 - DJANGO=1.10.7 diff --git a/requirements.txt b/requirements.txt index e8f9775..3fa91ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ Django -django-celery==3.1.17 +django-celery<4.0 pushjack==1.3.0 -djangorestframework>=3.0,<3.3 +djangorestframework>3.0 From da2b1bea0679beddfce2c9debe224dc3ce927576 Mon Sep 17 00:00:00 2001 From: Rakan Alhneiti Date: Sat, 5 Aug 2017 16:40:16 +0200 Subject: [PATCH 3/3] Fix minor bug in APNS --- pushy/dispatchers.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pushy/dispatchers.py b/pushy/dispatchers.py index bc0d71c..d9323b7 100644 --- a/pushy/dispatchers.py +++ b/pushy/dispatchers.py @@ -79,12 +79,14 @@ def _send(self, token, payload): try: response = self._client.send( [token], - alert=payload.pop('alert', None), - sound=payload.pop('sound', None), - badge=payload.pop('badge', None), - category=payload.pop('category', None), - content_available=True, - extra=payload or {} + message={ + 'alert': payload.pop('alert', None), + 'sound': payload.pop('sound', None), + 'badge': payload.pop('badge', None), + 'category': payload.pop('category', None), + 'content_available': True, + 'extra': payload or {} + } ) if response.errors: