diff --git a/backend/fcm.py b/backend/fcm.py new file mode 100644 index 000000000..3666717f1 --- /dev/null +++ b/backend/fcm.py @@ -0,0 +1,179 @@ +"""Pyfcm is a library to enhance and make it easier the communication +with firebase cloud messaging - fcm - from python.""" + +from pyfcm import FCMNotification + +from firebase_config import SERVER_KEY, FIREBASE_URL + +from firebase import _get_http + +import json + +from utils import validate_object + +FIREBASE_TOKENS_ENDPOINT = "%s/pushNotifications.json" % FIREBASE_URL + +ICON_URL = "https://firebasestorage.googleapis.com/v0/b/eciis-splab.appspot.com/o/images%2FLOGO-E-CIS-1510941864112?alt=media&token=ca197614-ad60-408e-b21e-0ebe258c4a80" + +# Instantiate a fcm service to the application server key +push_service = FCMNotification(api_key=SERVER_KEY) + + +def notify_single_user(data, user_key): + """Notify a single user. + It sends a notification to each user's device. + + Args: + data: An object that has the title, body and click_action + as properties. title is a string that represents the + notification's title. body is the body message of the notification, + the information you want pass to the users. click_action is the url + that the user is gonna be redirected when he click on the notification. + user_key: user's urlsafe key. + """ + tokens = get_single_user_tokens(user_key) + send_push_notifications(data, tokens) + + +def notify_multiple_users(data, user_keys): + """Notify multiple users. + This function receives a list of user_keys + and use it to retrieve the tokens. + + Args: + data: An object that has the title, body and click_action + as properties. title is a string that represents the + notification's title. body is the body message of the notification, + the information you want pass to the users. click_action is the url + that the user is gonna be redirected when he click on the notification. + user_keys: A list with all users' urlsafe keys that will + receive the notification. + """ + tokens = get_multiple_user_tokens(user_keys) + send_push_notifications(data, tokens) + + +def send_push_notifications(data, tokens): + """It wraps the call to pyfcm notify function. + + Args: + data: An object that has the title, body and click_action + as properties. title is a string that represents the + notification's title. body is the body message of the notification, + the information you want pass to the users. click_action is the url + that the user is gonna be redirected when he click on the notification. + tokens: The devices' tokens that will receive + the notification. + """ + validate_object(data, ['title', 'body', 'click_action']) + + title = data['title'] + body = data['body'] + click_action = data['click_action'] + + if tokens: + result = push_service.notify_multiple_devices( + registration_ids=tokens, message_title=title, + message_body=body, message_icon=ICON_URL, + click_action=click_action + ) + return result + + +def get_single_user_tokens(user_key): + """Calls get_tokens_from_firebase() + to get all the tokens and then filter them. + + Args: + user_key: The user ndb key.urlsafe(). + + Returns: + The user's tokens or an empty list when the user hasn't + enabled notifications yet. + """ + data = get_tokens_from_firebase(user_key) + tokens = filter_single_user_tokens(data) + return tokens + + +def get_multiple_user_tokens(users_keys): + """This function calls get_all_tokens_from_firebase() + to get all the tokens and then filter them using + filter_multiple_user_tokens function. + + Args: + users_keys: The users ndb key.urlsafe(). + + Returns: + The users' token or an empty list when None + of the users haven't enabled notifications yet. + """ + data = get_all_tokens_from_firebase() + tokens = filter_multiple_user_tokens(data, users_keys) + return tokens + + +def get_all_tokens_from_firebase(): + """This function only wraps the logic of + make a request to firebase to retrieve all + the tokens from the database. + + Returns: + The request's content parsed to json. + """ + response, content = _get_http().request(FIREBASE_TOKENS_ENDPOINT, method='GET') + return json.loads(content) + + +def get_tokens_from_firebase(user_key): + """It gets all tokens from the firebase + of the user whose key is user_key received as parameter. + + Args: + user_key: The user's urlsafe key. + + Returns: + The request's content parsed to json. + """ + firebase_endpoint = "%s/pushNotifications/%s.json" %(FIREBASE_URL, user_key) + response, content = _get_http().request(firebase_endpoint, method='GET') + return json.loads(content) + + +def filter_single_user_tokens(content): + """It loops through the content keys + and for each object it appends the + token property to the token's list. + + Args: + content: A json returned from firebase. + + Returns: + The user's tokens. + """ + tokens = [] + for key in content: + tokens.append(content[key]['token']) + return tokens + + +def filter_multiple_user_tokens(content, users_keys): + """For each user, represented by user_key + It get the user's firebase objects and loops through + each object getting the token. + + Args: + content: The json returned from firebase + users_keys: The users' keys who will receive the + notification. + + Returns: + The users' tokens. + """ + tokens = [] + for user_key in users_keys: + if user_key in content: + current_firebase_objects = content[user_key] + current_tokens = filter_single_user_tokens(current_firebase_objects) + map(lambda token: tokens.append(token), current_tokens) + return tokens diff --git a/backend/firebase.py b/backend/firebase.py index d831b069d..031a0f93e 100644 --- a/backend/firebase.py +++ b/backend/firebase.py @@ -13,8 +13,8 @@ _FIREBASE_SCOPES = [ 'https://www.googleapis.com/auth/firebase.database', - 'https://www.googleapis.com/auth/userinfo.email'] - + 'https://www.googleapis.com/auth/userinfo.email' +] @lru_cache() def _get_http(): @@ -44,4 +44,4 @@ def send_notification(user, message, entity_type, entity): message['timestamp'] = datetime.datetime.now().isoformat() message['entity_type'] = entity_type message['entity'] = entity - firebase_post(url, value=json.dumps(message)) + firebase_post(url, value=json.dumps(message)) \ No newline at end of file diff --git a/backend/handlers/institution_children_request_collection_handler.py b/backend/handlers/institution_children_request_collection_handler.py index 154e62b11..c2b5faf28 100644 --- a/backend/handlers/institution_children_request_collection_handler.py +++ b/backend/handlers/institution_children_request_collection_handler.py @@ -12,9 +12,25 @@ from models import Institution from models import InviteFactory from models import RequestInstitutionChildren +from service_entities import enqueue_task +from push_notification import NotificationType __all__ = ['InstitutionChildrenRequestCollectionHandler'] + +def enqueue_push_notification(requested_inst_key): + """Get the necessary parameters and insert + a new push notification in the queue. + """ + requested_inst = requested_inst_key.get() + receiver = requested_inst.admin.urlsafe() + + enqueue_task('send-push-notification', { + 'type': NotificationType.link.value, + 'receivers': [receiver], + 'entity': requested_inst_key.urlsafe() + }) + class InstitutionChildrenRequestCollectionHandler(BaseHandler): """Institution Children Request Collection Handler.""" @@ -72,4 +88,6 @@ def post(self, user, institution_urlsafe): request.send_invite(host, user.current_institution) + enqueue_push_notification(requested_inst_key) + self.response.write(json.dumps(request.make())) diff --git a/backend/handlers/institution_parent_request_collection_handler.py b/backend/handlers/institution_parent_request_collection_handler.py index cb736f96e..0a3e851d5 100644 --- a/backend/handlers/institution_parent_request_collection_handler.py +++ b/backend/handlers/institution_parent_request_collection_handler.py @@ -15,7 +15,8 @@ from util import Notification, NotificationsQueueManager from service_entities import enqueue_task from service_messages import create_message - +from service_entities import enqueue_task +from push_notification import NotificationType __all__ = ['InstitutionParentRequestCollectionHandler'] @@ -40,6 +41,20 @@ def remake_link(request, requested_inst_key, child_institution, user): request.change_status('accepted') + +def enqueue_push_notification(requested_inst_key): + """Get the necessary parameters and insert + a new push notification in the queue. + """ + requested_inst = requested_inst_key.get() + receiver = requested_inst.admin.urlsafe() + + enqueue_task('send-push-notification', { + 'type': NotificationType.link.value, + 'receivers': [receiver], + 'entity': requested_inst_key.urlsafe() + }) + class InstitutionParentRequestCollectionHandler(BaseHandler): """Institution Parent Collectcion Request Handler.""" @@ -125,4 +140,6 @@ def main_operations(request, requested_inst_key, child_institution, user, host): request = main_operations(request, requested_inst_key, child_institution, user, host) + enqueue_push_notification(requested_inst_key) + self.response.write(json.dumps(request.make())) diff --git a/backend/handlers/invite_user_collection_handler.py b/backend/handlers/invite_user_collection_handler.py index 150b68284..c7c7d6250 100644 --- a/backend/handlers/invite_user_collection_handler.py +++ b/backend/handlers/invite_user_collection_handler.py @@ -85,8 +85,16 @@ def process_invites(emails, invite, current_institution_key): else: invite = createInvite(invite) invites.append({'email': invite.invitee, 'key': invite.key.urlsafe()}) - enqueue_task('send-invite', {'invites_keys': json.dumps([invite.key.urlsafe()]), 'host': host, - 'current_institution': user.current_institution.urlsafe()}) + enqueue_task('send-invite', { + 'invites_keys': json.dumps([invite.key.urlsafe()]), + 'host': host, + 'current_institution': user.current_institution.urlsafe() + }) + + enqueue_task('send-push-notification', { + 'type': type_of_invite, + 'invites': json.dumps(map(lambda invite: invite['key'], invites)) + }) self.response.write(json.dumps( {'msg': 'The invites are being processed.', 'invites' : invites})) @@ -97,4 +105,4 @@ def createInvite(data): invite = InviteFactory.create(data, data['type_of_invite']) invite.put() - return invite \ No newline at end of file + return invite diff --git a/backend/handlers/like_handler.py b/backend/handlers/like_handler.py index 0fb2cab22..ddafd8738 100644 --- a/backend/handlers/like_handler.py +++ b/backend/handlers/like_handler.py @@ -85,6 +85,14 @@ def post(self, user, post_key, comment_id=None, reply_id=None): enqueue_task('post-notification', params) + is_first_like = post.get_number_of_likes() == 1 + if is_first_like: + enqueue_task('send-push-notification', { + 'type': entity_type, + 'receivers': [subscriber.urlsafe() for subscriber in post.subscribers], + 'entity': post.key.urlsafe() + }) + @json_response @login_required def delete(self, user, post_key, comment_id=None, reply_id=None): diff --git a/backend/handlers/post_comment_handler.py b/backend/handlers/post_comment_handler.py index 771b707d6..d310b1945 100644 --- a/backend/handlers/post_comment_handler.py +++ b/backend/handlers/post_comment_handler.py @@ -67,6 +67,14 @@ def post(self, user, post_key): } enqueue_task('post-notification', params) + is_first_comment = post.get_number_of_comment() == 1 + if is_first_comment: + enqueue_task('send-push-notification', { + 'type': entity_type, + 'receivers': [subscriber.urlsafe() for subscriber in post.subscribers], + 'entity': post.key.urlsafe() + }) + self.response.write(json.dumps(Utils.toJson(comment))) @json_response diff --git a/backend/models/post.py b/backend/models/post.py index 44dc5ce49..f082a8378 100644 --- a/backend/models/post.py +++ b/backend/models/post.py @@ -259,9 +259,8 @@ def get_number_of_comment(self): @ndb.transactional(retries=10) def add_comment(self, comment): """Add a comment to the post.""" - post = self.key.get() - post.comments[comment.id] = Utils.toJson(comment) - post.put() + self.comments[comment.id] = Utils.toJson(comment) + self.put() def remove_comment(self, comment): """Remove a commet from post.""" diff --git a/backend/push_notification/__init__.py b/backend/push_notification/__init__.py new file mode 100644 index 000000000..779236342 --- /dev/null +++ b/backend/push_notification/__init__.py @@ -0,0 +1,10 @@ +"""Initialize push_notification module.""" + +from .push_notification_service import * +from .send_push_notification_worker_handler import * + +notifications = [ + push_notification_service, send_push_notification_worker_handler +] + +__all__ = [prop for notification in notifications for prop in notification.__all__] diff --git a/backend/push_notification/push_notification_service.py b/backend/push_notification/push_notification_service.py new file mode 100644 index 000000000..bf035b1f8 --- /dev/null +++ b/backend/push_notification/push_notification_service.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +"""Push Notification Service.""" +from custom_exceptions import EntityException +from enum import Enum + +__all__ = ['get_notification_props', 'NotificationType'] + +def get_notification_props(_type, entity=None): + """This function represents the interface + the service's provides to the application + to get the notification properties. + + Args: + _type -- the notification's type. type: NotificationType + entity -- an optional parameter that can be + used to determine the click_action property + """ + notification = NotificationProperties(_type, entity) + return notification.get_props() + +class NotificationType(Enum): + """This Enum wraps the + possible notification's type + to make them more maintable + """ + like = 'LIKE_POST' + comment = 'COMMENT' + invite_user = 'USER' + invite_user_adm = 'USER_ADM' + link = 'LINK' + +class NotificationProperties(object): + """This class has several private + methods, each one for an especific + notification's type. These methods + return an object with the notification + properties. + To access them, the instance is initialized + with a notification_method which is set based on + the _type property received by the constructor, + this method is called in get_props, the unique + public method. + """ + + def __init__(self, _type, entity): + """Set the notification_method based on the _type. + types object helps this operation by maping a notification's + type to its especific method. + The entity, also, is set here. + """ + types = { + NotificationType.like: self.__get_like_props, + NotificationType.comment: self.__get_comment_props, + NotificationType.invite_user: self.__get_invite_user_props, + NotificationType.invite_user_adm: self.__get_invite_user_adm_props, + NotificationType.link: self.__get_link_props + } + self.entity = entity + self.notification_method = types[_type] + + def get_props(self): + """Just returns the result of + notification_method(). + """ + return self.notification_method() + + def __get_like_props(self): + """Responsible for return the right + properties for the like notification. + self.entity can't be None once it is + used to set the url of the click_action property. + """ + if not self.entity: + raise EntityException( + 'A LIKE_POST notification requires the entity.') + + url = '/posts/%s' % self.entity.key.urlsafe() + + return { + 'title': 'Publicação curtida', + 'body': 'Uma publicação de seu interesse foi curtida', + 'click_action': url + } + + def __get_comment_props(self): + """Responsible for return the right + properties for the comment notification. + self.entity can't be None once it is + used to set the url of the click_action property. + """ + if not self.entity: + raise EntityException( + 'A COMMENT notification requires the entity.') + url = "/posts/%s" % self.entity.key.urlsafe() + + return { + 'title': 'Publicação comentada', + 'body': 'Uma publicação do seu interesse foi comentada', + 'click_action': url + } + + def __get_invite_user_props(self): + """Responsible for return the right + properties for the invite_user notification. + """ + url = "/notifications" + + return { + 'title': 'Novo convite', + 'body': 'Você recebeu um novo convite para ser membro de uma instituição', + 'click_action': url + } + + def __get_invite_user_adm_props(self): + """Responsible for return the right + properties for the invite_user_adm notification. + """ + url = "/notifications" + + return { + 'title': 'Novo convite', + 'body': 'Você recebeu um novo convite para ser administrador de uma instituição', + 'click_action': url + } + + def __get_link_props(self): + """Responsible for return the right + properties for the link notification. + """ + url = "/institution/%s/inviteInstitution" % self.entity.key.urlsafe() + + return { + 'title': 'Solicitação de vínculo', + 'body': 'Uma instituição que você administra recebeu uma nova solicitação de vínculo', + 'click_action': url + } diff --git a/backend/push_notification/send_push_notification_worker_handler.py b/backend/push_notification/send_push_notification_worker_handler.py new file mode 100644 index 000000000..1718dc6d6 --- /dev/null +++ b/backend/push_notification/send_push_notification_worker_handler.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""Send Push Notification Worker Handler.""" +from handlers import BaseHandler +from google.appengine.ext import ndb +import json +from . import get_notification_props, NotificationType +from fcm import notify_multiple_users +from models import User + +__all__ = ['SendPushNotificationHandler'] + + +class SendPushNotificationHandler(BaseHandler): + """Handles send push notification operation + in the queue.""" + + def post(self): + """Sends a push notification to each user in the receivers list. + If only one user has to receive the notification, the length of the list + is going to be equal to 1.""" + entity = ndb.Key(urlsafe=self.request.get( + 'entity')).get() if self.request.get('entity') else None + receivers = self.get_receivers() + notification_type = NotificationType(self.request.get('type')) + notification_props = get_notification_props(notification_type, entity) + notify_multiple_users(notification_props, receivers) + + def get_users_from_invite(self, invite_keys): + """This function is called when the notification's + type is invite. + It iterates through the invite_keys retrieving the user + who will receive the invite and adding its key + in the user_keys method. + + Args: + invite_keys: An array with all the invites' keys that are + going to be send. + + Returns: + A list with all the users' keys who will receive invite. + """ + user_keys = [] + for invite_key in invite_keys: + invite = ndb.Key(urlsafe=invite_key).get() + user = User.get_active_user(invite.invitee) + user_key = user.key.urlsafe() if user else None + user_keys.append(user_key) + return user_keys + + def get_receivers(self): + """Responsible for check from where the receivers + have to be got. There are two possibilites, the receivers + can be retrieved from the invites, when the notification's type + is invite, or, they can be retrieved by the receivers property from + the request, what happens for the other types. + + Returns: + A list with all the users' keys who will receive the notification. + """ + return (self.get_users_from_invite(json.loads(self.request.get('invites'))) + if self.request.get('invites') else self.request.get_all('receivers')) diff --git a/backend/requirements.txt b/backend/requirements.txt index f1a4e4ef4..61c6aa82d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,7 +14,7 @@ pyasn1==0.4.2 pyasn1-modules==0.2.1 PyJWT==1.5.2 PyYAML==3.12 -requests==2.10.0 +requests==2.3 requests-toolbelt==0.8.0 rsa==3.4.2 six==1.10.0 @@ -22,4 +22,6 @@ waitress==1.1.0 webapp2==2.5.2 WebOb==1.7.3 WebTest==2.0.27 -jinja2==2.10 \ No newline at end of file +jinja2==2.10 +pyfcm==1.4.5 +enum34~=1.1.6 \ No newline at end of file diff --git a/backend/test/invite_handlers_tests/invite_user_collection_handler_test.py b/backend/test/invite_handlers_tests/invite_user_collection_handler_test.py index bcb28def1..38dde6725 100644 --- a/backend/test/invite_handlers_tests/invite_user_collection_handler_test.py +++ b/backend/test/invite_handlers_tests/invite_user_collection_handler_test.py @@ -8,7 +8,7 @@ from google.appengine.ext import ndb from handlers import InviteUserCollectionHandler from models import Invite, InviteUser -from mock import patch +from mock import patch, call @@ -86,15 +86,24 @@ def test_post_invite_user(self, enqueue_task, verify_token, create_sent_invites_ create_sent_invites_notification.assert_called_with(institution.key) - enqueue_task.assert_called_with( - 'send-invite', - { - 'invites_keys': json.dumps([invite.key.urlsafe()]), - 'host': response.request.host, - 'current_institution': institution.key.urlsafe(), - 'notifications_ids': ['some_notification_id'] - } - ) + send_invite_params = { + 'invites_keys': json.dumps([invite.key.urlsafe()]), + 'host': response.request.host, + 'current_institution': institution.key.urlsafe(), + 'notifications_ids': ['some_notification_id'] + } + + send_push_notification_params = { + 'type': 'USER', + 'invites': json.dumps([invite.key.urlsafe()]) + } + + calls = [ + call('send-invite', send_invite_params), + call('send-push-notification', send_push_notification_params) + ] + + enqueue_task.assert_has_calls(calls) @patch('util.login_service.verify_token') @@ -144,14 +153,23 @@ def test_post_invite_user_adm(self, enqueue_task, verify_token): self.assertEqual(expected_make, invite.make()) - enqueue_task.assert_called_with( - 'send-invite', - { - 'invites_keys': json.dumps([invite.key.urlsafe()]), - 'host': response.request.host, - 'current_institution': institution.key.urlsafe() - } - ) + send_invite_params = { + 'invites_keys': json.dumps([invite.key.urlsafe()]), + 'host': response.request.host, + 'current_institution': institution.key.urlsafe() + } + + send_push_notification_params = { + 'type': 'USER_ADM', + 'invites': json.dumps([invite.key.urlsafe()]) + } + + calls = [ + call('send-invite', send_invite_params), + call('send-push-notification', send_push_notification_params) + ] + + enqueue_task.assert_has_calls(calls) @patch('util.login_service.verify_token') def test_post_invalid_invite_type(self, verify_token): diff --git a/backend/test/like_handlers_tests/like_post_handler_test.py b/backend/test/like_handlers_tests/like_post_handler_test.py index 5a6a271cd..52835f71c 100644 --- a/backend/test/like_handlers_tests/like_post_handler_test.py +++ b/backend/test/like_handlers_tests/like_post_handler_test.py @@ -8,7 +8,7 @@ from handlers.like_handler import LikeHandler from .. import mocks -from mock import patch +from mock import patch, call class LikePostHandlerTest(TestBaseHandler): @@ -88,7 +88,7 @@ def test_post(self, verify_token, enqueue_task): "The number of likes expected was 1, but was %d" % self.post.get_number_of_likes()) # assert the notification was sent - params = { + post_not_params = { 'receiver_key': self.post.author.urlsafe(), 'sender_key': self.other_user.key.urlsafe(), 'entity_key': self.post.key.urlsafe(), @@ -97,7 +97,18 @@ def test_post(self, verify_token, enqueue_task): 'sender_institution_key': self.post.institution.urlsafe() } - enqueue_task.assert_called_with('post-notification', params) + push_not_params = { + 'entity': self.post.key.urlsafe(), + 'type': 'LIKE_POST', + 'receivers': [subscriber.urlsafe() for subscriber in self.post.subscribers] + } + + calls = [ + call('post-notification', post_not_params), + call('send-push-notification', push_not_params) + ] + + enqueue_task.assert_has_calls(calls) # Call the post method again with self.assertRaises(Exception) as exc: diff --git a/backend/test/post_handlers_tests/post_comment_handler_test.py b/backend/test/post_handlers_tests/post_comment_handler_test.py index df5a8d34f..54458ab7b 100644 --- a/backend/test/post_handlers_tests/post_comment_handler_test.py +++ b/backend/test/post_handlers_tests/post_comment_handler_test.py @@ -12,7 +12,7 @@ from models import Institution from models import Post -from mock import patch +from mock import patch, call USER_EMAIL = 'user@email.com' OTHER_USER_EMAIL = 'other_usero@email.com' @@ -80,7 +80,7 @@ def test_post(self, verify_token, enqueue_task): "Expected size of comment's list should be one") # assert the notification was sent - params = { + post_not_params = { 'receiver_key': self.user_post.author.urlsafe(), 'sender_key': self.other_user.key.urlsafe(), 'entity_key': self.user_post.key.urlsafe(), @@ -88,7 +88,19 @@ def test_post(self, verify_token, enqueue_task): 'current_institution': self.institution.key.urlsafe(), 'sender_institution_key': self.user_post.institution.urlsafe() } - enqueue_task.assert_called_with('post-notification', params) + + push_not_params = { + 'entity': self.user_post.key.urlsafe(), + 'type': 'COMMENT', + 'receivers': [subscriber.urlsafe() for subscriber in self.user_post.subscribers] + } + + calls = [ + call('post-notification', post_not_params), + call('send-push-notification', push_not_params) + ] + + enqueue_task.assert_has_calls(calls) # Verify that the post is published self.assertEquals(self.user_post.state, "published") diff --git a/backend/test/request_handlers_test/institution_children_request_collection_handler_test.py b/backend/test/request_handlers_test/institution_children_request_collection_handler_test.py index 4ffed98a3..3b01a9774 100644 --- a/backend/test/request_handlers_test/institution_children_request_collection_handler_test.py +++ b/backend/test/request_handlers_test/institution_children_request_collection_handler_test.py @@ -29,8 +29,9 @@ def setUp(cls): ], debug=True) cls.testapp = cls.webtest.TestApp(app) + @patch('handlers.institution_children_request_collection_handler.enqueue_task') @patch('util.login_service.verify_token', return_value=ADMIN) - def test_post(self, verify_token): + def test_post(self, verify_token, enqueue_task): """Test method post of InstitutionParentRequestCollectionHandler.""" admin = mocks.create_user(ADMIN['email']) institution = mocks.create_institution() @@ -78,6 +79,12 @@ def test_post(self, verify_token): request['type_of_invite'], 'REQUEST_INSTITUTION_CHILDREN', 'Expected sender type_of_invite is REQUEST_INSTITUTION_CHILDREN') + + enqueue_task.assert_called_with('send-push-notification', { + 'type': 'LINK', + 'receivers': [inst_requested.admin.urlsafe()], + 'entity': inst_requested.key.urlsafe() + }) @patch('util.login_service.verify_token', return_value=ADMIN) def test_post_with_wrong_institution(self, verify_token): @@ -121,6 +128,7 @@ def test_post_with_wrong_institution(self, verify_token): "Error! User is not allowed to send request", exception_message, "Expected error message is Error! User is not allowed to send request") + @patch('util.login_service.verify_token', return_value=USER) def test_post_user_not_admin(self, verify_token): @@ -235,4 +243,4 @@ def test_post_circular_hierarchy(self, verify_token): self.assertEqual( expected_message, exception_message, "The expected error message is not equal to the exception one") - \ No newline at end of file + diff --git a/backend/test/request_handlers_test/institution_parent_request_collection_handler_test.py b/backend/test/request_handlers_test/institution_parent_request_collection_handler_test.py index ded0da4b8..8c548bd18 100644 --- a/backend/test/request_handlers_test/institution_parent_request_collection_handler_test.py +++ b/backend/test/request_handlers_test/institution_parent_request_collection_handler_test.py @@ -9,7 +9,7 @@ from handlers.institution_parent_request_collection_handler import InstitutionParentRequestCollectionHandler from .. import mocks -from mock import patch +from mock import patch, call ADMIN = {'email': 'user1@gmail.com'} USER = {'email': 'otheruser@ccc.ufcg.edu.br'} @@ -28,8 +28,9 @@ def setUp(cls): ], debug=True) cls.testapp = cls.webtest.TestApp(app) + @patch('handlers.institution_parent_request_collection_handler.enqueue_task', return_value={}) @patch('util.login_service.verify_token', return_value=ADMIN) - def test_post(self, verify_token): + def test_post(self, verify_token, enqueue_task): """Test method post of InstitutionParentRequestCollectionHandler.""" admin = mocks.create_user(ADMIN['email']) institution = mocks.create_institution() @@ -81,6 +82,14 @@ def test_post(self, verify_token): self.assertEqual( institution.parent_institution, inst_requested.key, "The parent institution of inst test must be update to inst_requested") + + enqueue_task.assert_called_with( + 'send-push-notification', { + 'type': 'LINK', + 'receivers': [inst_requested.admin.urlsafe()], + 'entity': inst_requested.key.urlsafe() + } + ) @patch('util.login_service.verify_token', return_value=ADMIN) def test_post_with_wrong_institution(self, verify_token): diff --git a/backend/test/test_base_handler.py b/backend/test/test_base_handler.py index edc74cc5f..3d17d5fc7 100644 --- a/backend/test/test_base_handler.py +++ b/backend/test/test_base_handler.py @@ -17,7 +17,7 @@ def setUp(cls): cls.test.init_memcache_stub() cls.ndb.get_context().set_cache_policy(False) cls.test.init_search_stub() - + def get_message_exception(self, exception): """Return only message of string exception for tests.""" self.list_args = exception.split("\n") diff --git a/backend/test/util_modules_tests/fcm_test.py b/backend/test/util_modules_tests/fcm_test.py new file mode 100644 index 000000000..7fc0da8d4 --- /dev/null +++ b/backend/test/util_modules_tests/fcm_test.py @@ -0,0 +1,111 @@ +"""Fcm Module Test.""" + +from ..test_base import TestBase +from mock import patch +from .. import mocks +import fcm + + +class FcmTest(TestBase): + + @classmethod + def setUp(cls): + """Provide the base for the tests.""" + cls.test = cls.testbed.Testbed() + cls.test.activate() + cls.policy = cls.datastore.PseudoRandomHRConsistencyPolicy( + probability=1) + cls.test.init_datastore_v3_stub(consistency_policy=cls.policy) + cls.test.init_memcache_stub() + cls.f_user = mocks.create_user() + cls.s_user = mocks.create_user() + cls.f_user_key = cls.f_user.key.urlsafe() + cls.s_user_key = cls.s_user.key.urlsafe() + cls.notification_props = notification_props = { + 'title': 'test', + 'body': 'test', + 'click_action': '/' + } + + @patch('fcm.send_push_notifications') + @patch('fcm.get_single_user_tokens') + def test_notify_single_user(self, get_token, send_notification): + fcm.notify_single_user(self.notification_props, self.f_user_key) + + get_token.assert_called() + send_notification.assert_called() + + @patch('fcm.send_push_notifications') + @patch('fcm.get_multiple_user_tokens') + def test_notify_multiple_users(self, get_tokens, send_notification): + fcm.notify_multiple_users(self.notification_props, [self.f_user_key, self.s_user_key]) + + get_tokens.assert_called() + send_notification.assert_called() + + @patch('fcm.filter_single_user_tokens') + @patch('fcm.get_tokens_from_firebase') + def test_get_single_user_tokens(self, get_tokens, filter): + fcm.get_single_user_tokens(self.f_user_key) + + get_tokens.assert_called() + filter.assert_called() + + @patch('fcm.filter_multiple_user_tokens') + @patch('fcm.get_all_tokens_from_firebase') + def test_get_multiple_user_tokens(self, get_tokens, filter): + fcm.get_multiple_user_tokens([self.f_user_key, self.s_user_key]) + + get_tokens.assert_called() + filter.assert_called() + + def test_filter_single_user_tokens(self): + content = { + 'sapok-DOP': { + 'token': 'token-1' + }, + 'oakdopak-qopekqp': { + 'token': 'token-2' + }, + 'opkdsfa-OFDO': { + 'token': 'token-3' + } + } + + tokens = fcm.filter_single_user_tokens(content) + self.assertTrue(len(tokens) == 3, "The size of the list tokens is not the expected one") + self.assertTrue( + 'token-1' in tokens and + 'token-2' in tokens and + 'token-3' in tokens, + "The list tokens is not the expected one" + ) + + def test_filter_multiple_user_tokens(self): + content = { + self.f_user_key: { + 'sapok-DOP': { + 'token': 'token-1' + } + }, + self.s_user_key: { + 'oakdopak-qopekqp': { + 'token': 'token-2' + } + } + } + + users_keys = [self.f_user_key, self.s_user_key, 'aop-OPAKSD'] + + tokens = fcm.filter_multiple_user_tokens(content, users_keys) + self.assertTrue(len(tokens) == 2, + "The size of the list tokens is not the expected one") + self.assertTrue( + 'token-1' in tokens and + 'token-2' in tokens, + "The list tokens is not the expected one" + ) + + def tearDown(self): + """Deactivate the test.""" + self.test.deactivate() diff --git a/backend/test/util_modules_tests/push_notification_service_test.py b/backend/test/util_modules_tests/push_notification_service_test.py new file mode 100644 index 000000000..88cfeea04 --- /dev/null +++ b/backend/test/util_modules_tests/push_notification_service_test.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +"""Push Notification Service Test.""" + +from ..test_base import TestBase +from .. import mocks +from push_notification import get_notification_props, NotificationType +from custom_exceptions import EntityException + + +class PushNotificationServiceTest(TestBase): + + @classmethod + def setUp(cls): + """Provide the base for the tests.""" + cls.test = cls.testbed.Testbed() + cls.test.activate() + cls.policy = cls.datastore.PseudoRandomHRConsistencyPolicy( + probability=1) + cls.test.init_datastore_v3_stub(consistency_policy=cls.policy) + cls.test.init_memcache_stub() + cls.f_user = mocks.create_user() + cls.inst = mocks.create_institution() + cls.post = mocks.create_post(cls.f_user.key, cls.inst.key) + + + def test_like_post_notification(self): + """Test if the properties for LIKE_POST + notification are the expected.""" + url = "/posts/%s" %self.post.key.urlsafe() + + notification_props = get_notification_props(NotificationType('LIKE_POST'), self.post) + + self.assertEqual(notification_props['title'], 'Publicação curtida', + "The notification's title wasn't the expected one") + self.assertEqual( + notification_props['body'], 'Uma publicação de seu interesse foi curtida', + "The notification's body wasn't the expected one") + self.assertEqual(notification_props['click_action'], url, + "The click_action's url wasn't the expected one") + + def test_like_post_notification_without_entity(self): + """Test if a exception is raised when try to get + the notification props without an entity for LIKE_POST + notification.""" + with self.assertRaises(EntityException) as ex: + notification_props = get_notification_props(NotificationType('LIKE_POST')) + + error_message = ex.exception.message + + self.assertEqual( + error_message, 'A LIKE_POST notification requires the entity.', + "The error_message wasn't the expected one") + + def test_comment_notification(self): + """Test if the properties for COMMENT + notification are the expected.""" + url = "/posts/%s" % self.post.key.urlsafe() + + notification_props = get_notification_props(NotificationType('COMMENT'), self.post) + + self.assertEqual(notification_props['title'], 'Publicação comentada', + "The notification's title wasn't the expected one") + self.assertEqual( + notification_props['body'], 'Uma publicação do seu interesse foi comentada', + "The notification's body wasn't the expected one") + self.assertEqual(notification_props['click_action'], url, + "The click_action wasn't the expected one") + + def test_comment_notification_without_entity(self): + """Test if a exception is raised when try to get + the notification props without an entity for LIKE_POST + notification.""" + with self.assertRaises(EntityException) as ex: + notification_props = get_notification_props(NotificationType('COMMENT')) + + error_message = ex.exception.message + + self.assertEqual( + error_message, 'A COMMENT notification requires the entity.', + "The error_message wasn't the expected one") + + def test_invite_user_notification(self): + """Test if the properties for USER + notification are the expected.""" + url = "/notifications" + + notification_props = get_notification_props(NotificationType('USER')) + + self.assertEqual(notification_props['title'], 'Novo convite', + "The notification's title wasn't the expected one") + self.assertEqual(notification_props['body'], + 'Você recebeu um novo convite para ser membro de uma instituição', + "The notification's body wasn't the expected one") + self.assertEqual(notification_props['click_action'], url, + "The click_action wasn't the expected one") + + def test_invite_user_adm_notification(self): + """Test if the properties for USER_ADM + notification are the expected.""" + url = "/notifications" + + notification_props = get_notification_props(NotificationType('USER_ADM')) + + self.assertEqual(notification_props['title'], 'Novo convite', + "The notification's title wasn't the expected one") + self.assertEqual(notification_props['body'], + 'Você recebeu um novo convite para ser administrador de uma instituição', + "The notification's body wasn't the expected one") + self.assertEqual(notification_props['click_action'], url, + "The click_action wasn't the expected one") diff --git a/backend/test/worker_handlers_tests/send_invite_handler_test.py b/backend/test/worker_handlers_tests/send_invite_handler_test.py index 77abe7341..1378eaeea 100644 --- a/backend/test/worker_handlers_tests/send_invite_handler_test.py +++ b/backend/test/worker_handlers_tests/send_invite_handler_test.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """Send Invite Handler Tests.""" from ..test_base_handler import TestBaseHandler @@ -49,8 +50,8 @@ def test_post(self, send_invite, resolve_notification_task): second_invite.key.urlsafe() ] - request_url = '/api/queue/send-invite?invites_keys=%s&host=%s¤t_institution=%s¬ifications_ids=%s' % ( - json.dumps(invites_keys), host, institution.key.urlsafe(), notification_id) + request_url = '/api/queue/send-invite?invites_keys=%s&host=%s¤t_institution=%s¬ifications_ids=%s&type_of_invite=%s' % ( + json.dumps(invites_keys), host, institution.key.urlsafe(), notification_id, 'USER') self.testapp.post(request_url) @@ -58,4 +59,3 @@ def test_post(self, send_invite, resolve_notification_task): send_invite.assert_called_with(host, institution.key) resolve_notification_task.assert_called_with(notification_id) - \ No newline at end of file diff --git a/backend/test/worker_handlers_tests/send_push_notification_handler_test.py b/backend/test/worker_handlers_tests/send_push_notification_handler_test.py new file mode 100644 index 000000000..df26a149c --- /dev/null +++ b/backend/test/worker_handlers_tests/send_push_notification_handler_test.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +"""Send push notification handler test.""" + +from ..test_base_handler import TestBaseHandler +from push_notification import SendPushNotificationHandler, get_notification_props, NotificationType +import json +from .. import mocks +from mock import patch + +MAIN_URI = '/api/queue/send-push-notification' + + +class SendPushNotificationHandlerTest(TestBaseHandler): + """Test Send Push Notification Handler.""" + + @classmethod + def setUp(cls): + """Provide the base for the tests.""" + super(SendPushNotificationHandlerTest, cls).setUp() + app = cls.webapp2.WSGIApplication( + [ + (MAIN_URI, + SendPushNotificationHandler) + ], debug=True) + cls.testapp = cls.webtest.TestApp(app) + + @patch('push_notification.send_push_notification_worker_handler.User.get_active_user') + @patch('push_notification.send_push_notification_worker_handler.notify_multiple_users') + def test_send_notification_with_regular_receivers(self, notify, get_user): + """Test if the notify's method is called + properly with the regular receivers.""" + user = mocks.create_user() + inst = mocks.create_institution() + post = mocks.create_post(user.key, inst.key) + post.add_subscriber(user) + notification_type = 'LIKE_POST' + + body = { + 'type': notification_type, + 'receivers': [user.key.urlsafe()], + 'entity': post.key.urlsafe() + } + + self.testapp.post(MAIN_URI, body) + + props = get_notification_props(NotificationType(notification_type), post) + + notify.assert_called_with(props, [user.key.urlsafe()]) + get_user.assert_not_called() + + @patch('push_notification.send_push_notification_worker_handler.User.get_active_user') + @patch('push_notification.send_push_notification_worker_handler.notify_multiple_users') + def test_send_notification_with_invites(self, notify, get_user): + """Test if the notify's method is called + properly without the receivers as parameter.""" + user = mocks.create_user() + inst = mocks.create_institution() + post = mocks.create_post(user.key, inst.key) + post.add_subscriber(user) + notification_type = 'USER' + f_invite = mocks.create_invite(user, inst.key, 'USER') + s_user = mocks.create_user() + s_invite = mocks.create_invite(s_user, inst.key, 'USER') + user.state = 'active' + s_user.state = 'active' + user.put() + s_user.put() + + invites = [f_invite.make(), s_invite.make()] + body = { + 'type': notification_type, + 'invites': json.dumps(map(lambda invite: invite['key'], invites)) + } + + self.testapp.post(MAIN_URI, body) + + notify.assert_called() + get_user.assert_called() + diff --git a/backend/utils.py b/backend/utils.py index cbd8ed24e..66f3b5bcc 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -14,8 +14,7 @@ from oauth2client.crypt import AppIdentityError from custom_exceptions import NotAuthorizedException -from custom_exceptions import QueryException - +from custom_exceptions import QueryException, FieldException class Utils(): @@ -173,3 +172,13 @@ def text_normalize(text): normal_form_text = normalize('NFKD', unicode(text)).encode('ascii', 'ignore') text_ignoring_escape_chars = normal_form_text.encode('unicode-escape') return text_ignoring_escape_chars + +def validate_object(obj, props): + """It iterates in props list and + check if each one is in the obj's keys.""" + for prop in props: + Utils._assert( + prop not in obj, + "The %s property is not in the object" %prop, + FieldException + ) diff --git a/backend/worker.py b/backend/worker.py index 1e0a3e7d4..c1f51e25c 100644 --- a/backend/worker.py +++ b/backend/worker.py @@ -19,6 +19,7 @@ from permissions import DEFAULT_ADMIN_PERMISSIONS from send_email_hierarchy import RemoveInstitutionEmailSender from util import NotificationsQueueManager +from push_notification import SendPushNotificationHandler def should_remove(user, inst_key_urlsafe, transfer_inst_key_urlsafe): @@ -286,6 +287,7 @@ def post(self): message=notification_message ) + class EmailMembersHandler(BaseHandler): """Handle requests to send emails to institution members.""" @@ -509,5 +511,6 @@ def save_changes(admin, new_admin, institution): ('/api/queue/add-admin-permissions', AddAdminPermissionsInInstitutionHierarchy), ('/api/queue/remove-admin-permissions', RemoveAdminPermissionsInInstitutionHierarchy), ('/api/queue/send-invite', SendInviteHandler), - ('/api/queue/transfer-admin-permissions', TransferAdminPermissionsHandler) + ('/api/queue/transfer-admin-permissions', TransferAdminPermissionsHandler), + ('/api/queue/send-push-notification', SendPushNotificationHandler) ], debug=True) diff --git a/ecis b/ecis index de5e2bae3..59d4b86d3 100755 --- a/ecis +++ b/ecis @@ -256,6 +256,7 @@ case "$1" in cd backend echo 'FIREBASE_URL = "FIREBASE_URL"' > firebase_config.py + echo 'SERVER_KEY = "SERVER_KEY"' >> firebase_config.py TEST_NAME="*test.py" if [ "$3" == "--name" ] && [ ! -z "$4" ]; then diff --git a/frontend/auth/authService.js b/frontend/auth/authService.js index 6ca7568a6..70a748532 100644 --- a/frontend/auth/authService.js +++ b/frontend/auth/authService.js @@ -4,7 +4,7 @@ var app = angular.module("app"); app.service("AuthService", function AuthService($q, $state, $window, UserService, - MessageService) { + MessageService, PushNotificationService) { var service = this; var authObj = firebase.auth(); @@ -121,6 +121,7 @@ if (user.emailVerified) { return user.getIdToken(true).then(function(idToken) { return service.setupUser(idToken, user.emailVerified).then(function success(userInfo) { + PushNotificationService.requestNotificationPermission(service.getCurrentUser()); return userInfo; }); }); diff --git a/frontend/index.html b/frontend/index.html index 3947152d3..fcfc07fbf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -209,6 +209,7 @@ + diff --git a/frontend/notification/pushNotificationService.js b/frontend/notification/pushNotificationService.js new file mode 100644 index 000000000..d1f5c5d83 --- /dev/null +++ b/frontend/notification/pushNotificationService.js @@ -0,0 +1,134 @@ +(function() { + 'use strict'; + + const app = angular.module('app'); + + app.service('PushNotificationService', function PushNotificationService($firebaseArray, + $firebaseObject, MessageService) { + /** + * Service responsible for send request permission + * to enable notifications to the user and for deal + * with the token resulted from this operation by saving + * or updating it in firebase database. + * Just in case the user is on a mobile device. + */ + const service = this; + + /** + * Retrieves the application instance of + * firebase messaging. + */ + const messaging = firebase.messaging(); + + const ref = firebase.database().ref(); + + const PUSH_NOTIFICATIONS_URL = "pushNotifications/"; + + /** + * @private + */ + service._isMobile = { + Android: () => { + return navigator.userAgent.match(/Android/i); + }, + BlackBerry: () => { + return navigator.userAgent.match(/BlackBerry/i); + }, + iOS: () => { + return navigator.userAgent.match(/iPhone|iPad|iPod/i); + }, + Opera: () => { + return navigator.userAgent.match(/Opera Mini/i); + }, + Windows: () => { + return navigator.userAgent.match(/IEMobile/i); + }, + any: () => { + return ( + service._isMobile.Android() || + service._isMobile.BlackBerry() || + service._isMobile.iOS() || + service._isMobile.Opera() || + service._isMobile.Windows() + ); + } + }; + + service.firebaseArrayNotifications; + + /** + * Ask permission to the user to send push notifications + * and if the permission is conceded the user's new token + * is retrieved and saveToken is called passing the token + * as parameter. + */ + service.requestNotificationPermission = function requestNotificationPermission(user) { + service.currentUser = user; + const isOnMobile = service._isMobile.any(); + if (!service._hasNotificationPermission() && isOnMobile) { + messaging.requestPermission().then(() => { + messaging.getToken().then(token => { + service._saveToken(token); + }, () => { + MessageService.showToast('Não foi possível ativar as notificações.'); + }); + }); + } + }; + + /** + * It receives a token, starts a reference to + * firebase's database and save the token. + * @param {String} token + * @private + */ + service._saveToken = function saveToken(token) { + const notificationsRef = service._initFirebaseArray(); + service._setToken(token, notificationsRef); + }; + + /** + * Instantiate a reference to the database + * based on the userKey, starts a firebaseArray + * in case of it hasn't been started before, and + * return the reference. + * @private + */ + service._initFirebaseArray = function initFirebaseArray() { + const endPoint = `${PUSH_NOTIFICATIONS_URL}${service.currentUser.key}`; + const notificationsRef = ref.child(endPoint); + + if (!service.firebaseArrayNotifications) { + service.firebaseArrayNotifications = $firebaseArray(notificationsRef); + } + + return notificationsRef; + }; + + /** + * It is responsible for check if the user already have a token. + * If it does, the token is replaced by the new one received as parameter. + * Otherwise the token is saved. + * @param {String} token + * @param {object} notificationsRef + * @private + */ + service._setToken = function setToken(token, notificationsRef) { + service.firebaseArrayNotifications.$loaded().then(() => { + const tokenObject = $firebaseObject(notificationsRef); + tokenObject.token = token; + service.firebaseArrayNotifications.$add(tokenObject); + }); + }; + + /** + * Check if the user has already conceded the permission + * using Notification object. + * @private + */ + service._hasNotificationPermission = function hasNotificationPermission() { + const { permission } = Notification; + return permission === "granted"; + }; + }); +})(); \ No newline at end of file diff --git a/frontend/test/init.js b/frontend/test/init.js index 2b9f5ba5a..f271fb525 100644 --- a/frontend/test/init.js +++ b/frontend/test/init.js @@ -9,7 +9,8 @@ apiKey: "MOCK-API_KEY", authDomain: "eciis-splab.firebaseapp.com", // Your Firebase Auth domain ("*.firebaseapp.com") databaseURL: "https://eciis-splab.firebaseio.com", // Your Firebase Database URL ("https://*.firebaseio.com") - storageBucket: "eciis-splab.appspot.com" + storageBucket: "eciis-splab.appspot.com", + messagingSenderId: "jdsfkbcbmnweuiyeuiwyhdjskalhdjkhjk" }); var user = { diff --git a/frontend/test/specs/notification/pushNotificationServiceSpec.js b/frontend/test/specs/notification/pushNotificationServiceSpec.js new file mode 100644 index 000000000..6196fb0b7 --- /dev/null +++ b/frontend/test/specs/notification/pushNotificationServiceSpec.js @@ -0,0 +1,143 @@ +'use strict'; + +(describe('Test PushNotificationService', function () { + var service, messaging, defaultToken, notificationsRef, messageService; + + const fakeCallback = function fakeCallback(data) { + return { + then: function (callback) { + return callback(data); + } + }; + }; + + beforeEach(module('app')); + + beforeEach(inject(function (PushNotificationService, MessageService) { + service = PushNotificationService; + messaging = firebase.messaging(); + messageService = MessageService; + var ref = firebase.database().ref(); + notificationsRef = ref.child("notifications/key"); + defaultToken = 'oaspkd-OPASKDAPO'; + })); + + describe('requestNotificationPermission', () => { + beforeEach(() => { + spyOn(service, '_hasNotificationPermission').and.returnValue(false); + spyOn(service._isMobile, 'any').and.returnValue(true); + }); + + it('should call saveToken', () => { + spyOn(messaging, 'requestPermission').and.callFake(fakeCallback); + spyOn(messaging, 'getToken').and.callFake(fakeCallback); + spyOn(service, '_saveToken').and.callFake(fakeCallback); + + service.requestNotificationPermission(new User({})); + + expect(messaging.requestPermission).toHaveBeenCalled(); + expect(messaging.getToken).toHaveBeenCalled(); + expect(service._saveToken).toHaveBeenCalled(); + }); + + it('should not call saveToken when the user does not enable notification', () => { + spyOn(messaging, 'requestPermission').and.callFake(function() { + return { + then: function () { + return; + } + } + }); + spyOn(messaging, 'getToken'); + spyOn(service, '_saveToken'); + + service.requestNotificationPermission(); + + expect(messaging.requestPermission).toHaveBeenCalled(); + expect(messaging.getToken).not.toHaveBeenCalled(); + expect(service._saveToken).not.toHaveBeenCalled(); + }); + + it('should call showToast', () => { + spyOn(messaging, 'requestPermission').and.callFake(fakeCallback); + spyOn(messaging, 'getToken').and.callFake(() => { + return { + then: (success, error) => { + return error(); + } + } + }); + spyOn(service, '_saveToken'); + spyOn(messageService, 'showToast'); + + service.requestNotificationPermission(); + + expect(messaging.requestPermission).toHaveBeenCalled(); + expect(messaging.getToken).toHaveBeenCalled(); + expect(service._saveToken).not.toHaveBeenCalled(); + expect(messageService.showToast).toHaveBeenCalledWith('Não foi possível ativar as notificações.'); + }); + }); + + describe('saveToken', () => { + it('should initFirebaseArray and setToken', () => { + spyOn(service, '_initFirebaseArray').and.returnValue(notificationsRef); + spyOn(service, '_setToken'); + + service._saveToken(defaultToken); + + expect(service._initFirebaseArray).toHaveBeenCalled(); + expect(service._setToken).toHaveBeenCalledWith(defaultToken, notificationsRef); + }); + }); + + describe('initFirebaseArray', () => { + it('should starts a firebaseArray', () => { + expect(service.firebaseArrayNotifications).toBe(undefined); + service.currentUser = new User({ + key: 'aopskdpoaAPOSDKAPOKDPK' + }); + + service._initFirebaseArray(); + + expect(service.firebaseArrayNotifications).not.toBe(undefined); + }); + }); + + describe('setToken', () => { + beforeEach(() => { + service.currentUser = new User({ + key: 'aopskdpoaAPOSDKAPOKDPK' + }); + service._initFirebaseArray(); + }); + + it('should call $add', () => { + spyOn(service.firebaseArrayNotifications, '$loaded').and.callFake(fakeCallback); + spyOn(service.firebaseArrayNotifications, '$add').and.callFake(fakeCallback); + + service._setToken(defaultToken, notificationsRef); + + expect(service.firebaseArrayNotifications.$loaded).toHaveBeenCalled(); + expect(service.firebaseArrayNotifications.$add).toHaveBeenCalled(); + }); + }); + + describe('hasNotificationPermission', () => { + it('should return true when the user has enabled notifications', () => { + Notification = {permission: 'granted'}; + + const result = service._hasNotificationPermission(); + + expect(result).toEqual(true); + }); + + it('should return false when the user has not enabled notifications', () => { + Notification = {permission: 'ask'}; + + const result = service._hasNotificationPermission(); + + expect(result).toEqual(false); + }); + }); +})); \ No newline at end of file