From 8e516254fb5b7636e2440b82e5642bb412623239 Mon Sep 17 00:00:00 2001
From: Carina Antunes <carina.oliveira.antunes@cern.ch>
Date: Mon, 15 Aug 2022 11:23:11 +0200
Subject: [PATCH] [#76] Safari push consumer: replace apns2 library with
 aioapns

---
 .isort.cfg                                    |   2 +-
 logging.yaml                                  |   4 +
 notifications_consumer/consumer.py            |   2 +-
 .../megabus/client_individual_consumer.py     |   4 +-
 notifications_consumer/processors/registry.py |  12 +-
 .../processors/safaripush/client.py           |  79 +++++++++++++
 .../processors/safaripush/processor.py        |  18 ++-
 .../processors/safaripush/utils.py            |  39 -------
 poetry.lock                                   | 107 +++++++-----------
 pyproject.toml                                |   2 +-
 .../unit/test_processors_safaripush_utils.py  |  18 ++-
 11 files changed, 162 insertions(+), 125 deletions(-)
 create mode 100644 notifications_consumer/processors/safaripush/client.py

diff --git a/.isort.cfg b/.isort.cfg
index e6663ff..5b68580 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -4,4 +4,4 @@ multi_line_output=3
 include_trailing_comma=True
 lines_after_imports=2
 not_skip=__init__.py
-known_third_party = Crypto,apns2,bs4,cachetools,etcd3,jinja2,lxml,markdownify,mattermostdriver,megabus,pytest,pywebpush,requests,smail,sqlalchemy,stomp,yaml
+known_third_party = Crypto,aioapns,bs4,cachetools,etcd3,jinja2,lxml,markdownify,mattermostdriver,megabus,pytest,pywebpush,requests,smail,sqlalchemy,stomp,yaml
diff --git a/logging.yaml b/logging.yaml
index 79d7459..115a4c0 100644
--- a/logging.yaml
+++ b/logging.yaml
@@ -36,6 +36,10 @@ loggers:
     level: WARNING
     handlers: []
     propagate: True
+  aioapns:
+    level: WARNING
+    handlers: []
+    propagate: True
 
 root:
   level: INFO
diff --git a/notifications_consumer/consumer.py b/notifications_consumer/consumer.py
index 57762a7..82b3cdb 100644
--- a/notifications_consumer/consumer.py
+++ b/notifications_consumer/consumer.py
@@ -14,7 +14,7 @@ class NotificationsConsumer(ClientIndividualListener):
         """Initialize the Notifications consumer."""
         ClientIndividualListener.__init__(self)
         self.config = config
-        self.processor = ProcessorRegistry.processor(self.config.PROCESSOR, config)
+        self.processor = ProcessorRegistry.processor(self.config.PROCESSOR, config, **kwargs)
         self.kwargs = kwargs
 
     def on_message(self, message, headers):
diff --git a/notifications_consumer/megabus/client_individual_consumer.py b/notifications_consumer/megabus/client_individual_consumer.py
index a306fc1..446c7f8 100644
--- a/notifications_consumer/megabus/client_individual_consumer.py
+++ b/notifications_consumer/megabus/client_individual_consumer.py
@@ -1,4 +1,5 @@
 """Client Individual Consumer definition."""
+import asyncio
 import logging
 import threading
 import time
@@ -64,6 +65,7 @@ class ClientIndividualConsumer(Consumer):
         self._listeners = []  # type : List[ClientIndividualListener]
         self._hosts = []  # type : List[str]
         self._on_disconnect_evt = threading.Event()
+        self._asyncio_loop = asyncio.get_event_loop()
 
         # Auto connect must be called until _listener_kwargs are set
         if auto_connect:
@@ -76,7 +78,7 @@ class ClientIndividualConsumer(Consumer):
         self,
     ):
         """Create a new instance of the Listener class."""
-        return self._external_listener(**self._listener_kwargs)
+        return self._external_listener(**self._listener_kwargs, asyncio_loop=self._asyncio_loop)
 
     def __reconnect(self):
         self._disconnect()
diff --git a/notifications_consumer/processors/registry.py b/notifications_consumer/processors/registry.py
index 2f11ae3..602e901 100644
--- a/notifications_consumer/processors/registry.py
+++ b/notifications_consumer/processors/registry.py
@@ -24,15 +24,19 @@ class ProcessorRegistry:
         return processor_cls
 
     @classmethod
-    def processor(cls, processor_id, config):
+    def processor(cls, processor_id, config, **kwargs):
         """Return a new processor instance."""
         processor_cls = cls.registry[processor_id]
-        return processor_cls(**build_kwargs(config))
+        return processor_cls(**build_kwargs(config, **kwargs))
 
 
-def build_kwargs(config: Config) -> dict:
+def build_kwargs(config: Config, **kwargs) -> dict:
     """Build processor kwargs."""
-    kwargs = dict(config=config)
+    if not kwargs:
+        kwargs = dict()
+
+    kwargs["config"] = config
+
     if config.PUBLISHER_NAME:
         kwargs["publisher"] = Publisher(config.PUBLISHER_NAME)
 
diff --git a/notifications_consumer/processors/safaripush/client.py b/notifications_consumer/processors/safaripush/client.py
new file mode 100644
index 0000000..637c646
--- /dev/null
+++ b/notifications_consumer/processors/safaripush/client.py
@@ -0,0 +1,79 @@
+"""safaripush notifications utils."""
+import asyncio
+import html
+import logging
+import re
+import unicodedata
+
+from aioapns import APNs, NotificationRequest
+
+from notifications_consumer.config import Config
+from notifications_consumer.processors.safaripush.utils import remove_http
+
+
+class ThreadSafeSafariPushClient:
+    """Thread safe Apple Push Notification Service Wrapper.
+
+    A sync client like apns2 would be better to avoid handling asyncio and threads together.
+    However at the time, there's no maintained apsn client except the implemented in this class: aioapns.
+    We should revisit apns2 eventually: https://github.com/Pr0Ger/PyAPNs2/issues/126#issuecomment-1008424954.
+    """
+
+    def __init__(self, asyncio_loop):
+        """Initialize APN Client."""
+        self._monkey_patch_asyncio_event_loop(asyncio_loop)
+
+        self.client = APNs(
+            client_cert=Config.APPLE_SAFARI_PUSH_CERT_KEY_FILE_PATH,
+            topic=Config.APPLE_SAFARI_PUSH_WEBSITEPUSHID,
+            use_sandbox=False,
+        )
+
+    @staticmethod
+    def _monkey_patch_asyncio_event_loop(loop):
+        """Monkey patch event loop for asyncio-threads compatibility in the current module.
+
+        Threads created externally in stomp/python-megabus don't have access to the asyncio loop, which is only
+        available from the Main Thread. The current workaround is to receive the loop created in the main thread,
+        and monkeypatch asyncio.get_event_loop so it always sets the loop for the current thread before returning it.
+        """
+
+        def _patch():
+            asyncio.set_event_loop(loop)
+            return loop
+
+        asyncio.get_running_loop = _patch
+        asyncio.get_event_loop = _patch
+
+    @staticmethod
+    def _create_request(summary: str, body: str, url: str, image: str, device_token: str):
+        # strip tags, html decode and replace unicode blobs
+        text_content = unicodedata.normalize("NFKD", html.unescape(re.sub("<.*?>", "", body)))
+
+        # click URL needs to be cleaned from leading http(s):// due to definition set to https://%s in the
+        # pushPackage.zip
+        return NotificationRequest(
+            device_token=device_token,
+            message={
+                "aps": {
+                    "alert": {
+                        "title": summary,
+                        "body": text_content[:3000],
+                    },
+                    "url-args": [remove_http(url)],
+                }
+            },
+        )
+
+    async def _send_notification(self, request: NotificationRequest):
+        logging.debug("Running async ThreadSafeSafariPushClient._send_message in main thread event loop")
+        await self.client.send_notification(request)
+
+    def send_message(self, summary: str, body: str, url: str, image: str, device_token: str):
+        """Create json blob to be sent via safaripush."""
+        request = self._create_request(summary, body, url, image, device_token)
+        logging.debug("safaripush send_message to %s", device_token)
+        future = asyncio.run_coroutine_threadsafe(self._send_notification(request), asyncio.get_running_loop())
+        # Wait for the result with a timeout
+        assert future.result(timeout=3) is None
+        logging.debug("Safari message sent")
diff --git a/notifications_consumer/processors/safaripush/processor.py b/notifications_consumer/processors/safaripush/processor.py
index 8123d60..b9e53db 100644
--- a/notifications_consumer/processors/safaripush/processor.py
+++ b/notifications_consumer/processors/safaripush/processor.py
@@ -6,13 +6,18 @@ from bs4 import BeautifulSoup
 
 from notifications_consumer.processors.processor import Processor
 from notifications_consumer.processors.registry import ProcessorRegistry
-from notifications_consumer.processors.safaripush.utils import create_message, send_message
+from notifications_consumer.processors.safaripush.client import ThreadSafeSafariPushClient
 
 
 @ProcessorRegistry.register
 class SafariPushProcessor(Processor):
     """Send safaripush notifications."""
 
+    def __init__(self, asyncio_loop, **kwargs):
+        """Initialize client."""
+        Processor.__init__(self, **kwargs)
+        self.client = ThreadSafeSafariPushClient(asyncio_loop)
+
     __id = "safaripush"
 
     @classmethod
@@ -34,24 +39,17 @@ class SafariPushProcessor(Processor):
         subject = f'[{kwargs["channel_name"]}] - {kwargs["summary"]}'
         message_body = BeautifulSoup(kwargs["message_body"], "lxml").get_text(" ")
 
-        spmessage = create_message(
-            subject,
-            message_body,
-            kwargs["link"],
-            kwargs["img_url"],
-        )
-
         if self.auditor.get_audit_notification(kwargs["notification_id"], device_id, email):
             logging.info(
                 "%s is already sent to %s according ot etcd, skipping", kwargs["notification_id"], device_token
             )
             return
 
-        ret = send_message(spmessage, device_token)
+        self.client.send_message(subject, message_body, kwargs["link"], kwargs["img_url"], device_token)
+
         self.auditor.audit_notification(
             kwargs["notification_id"], {"event": "Sent", "device_token": device_token}, device_id, email
         )
-        return ret
 
     def read_message(self, message: Dict):
         """Read the message."""
diff --git a/notifications_consumer/processors/safaripush/utils.py b/notifications_consumer/processors/safaripush/utils.py
index 658d6ad..08011da 100644
--- a/notifications_consumer/processors/safaripush/utils.py
+++ b/notifications_consumer/processors/safaripush/utils.py
@@ -1,13 +1,4 @@
 """safaripush notifications utils."""
-import html
-import logging
-import re
-import unicodedata
-
-from apns2.client import APNsClient
-from apns2.payload import Payload
-
-from notifications_consumer.config import Config
 
 
 # Strip start http:// or https:// from url
@@ -20,33 +11,3 @@ def remove_http(inputstring):
     if inputstring.startswith("http://"):
         return inputstring[7:]
     return inputstring
-
-
-def create_message(summary: str, body: str, url: str, image: str):
-    """Create json blob to be sent via safaripush."""
-    # strip tags, html decode and replace unicode blobs
-    text_content = unicodedata.normalize("NFKD", html.unescape(re.sub("<.*?>", "", body)))
-
-    # click URL needs to be cleaned from leading http(s):// due to definition set to https://%s in the pushPackage.zip
-    spmessage = Payload(
-        alert={
-            "title": summary,
-            "body": text_content[:3000],
-        }
-    )
-    spmessage.url_args = [remove_http(url)]
-
-    return spmessage
-
-
-def send_message(payload: Payload, device_token: str):
-    """Send the message Payload.
-
-    :param payload: apns2 Payload object
-    :param device_token: Device token
-    """
-    logging.debug("safaripush send_message to %s", device_token)
-    topic = Config.APPLE_SAFARI_PUSH_WEBSITEPUSHID
-    client = APNsClient(Config.APPLE_SAFARI_PUSH_CERT_KEY_FILE_PATH, use_sandbox=False, use_alternative_port=False)
-    client.send_notification(device_token, payload, topic)
-    logging.debug("Safari message sent")
diff --git a/poetry.lock b/poetry.lock
index 072f634..002abd7 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,3 +1,16 @@
+[[package]]
+name = "aioapns"
+version = "2.1"
+description = "An efficient APNs Client Library for Python/asyncio"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+h2 = ">=4.0.0"
+pyjwt = ">=2.0.0"
+pyOpenSSL = ">=17.5.0"
+
 [[package]]
 name = "aiohttp"
 version = "3.8.1"
@@ -32,22 +45,6 @@ python-versions = ">=3.6"
 [package.dependencies]
 frozenlist = ">=1.1.0"
 
-[[package]]
-name = "apns2"
-version = "0.7.2"
-description = "A python library for interacting with the Apple Push Notification Service via HTTP/2 protocol"
-category = "main"
-optional = false
-python-versions = "*"
-
-[package.dependencies]
-cryptography = ">=1.7.2"
-hyper = ">=0.7"
-PyJWT = ">=1.4.0,<2.0.0"
-
-[package.extras]
-tests = ["freezegun", "pytest"]
-
 [[package]]
 name = "appnope"
 version = "0.1.3"
@@ -298,23 +295,23 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "h2"
-version = "2.6.2"
+version = "4.1.0"
 description = "HTTP/2 State-Machine based protocol implementation"
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=3.6.1"
 
 [package.dependencies]
-hpack = ">=2.2,<4"
-hyperframe = ">=3.1,<4.0.0 || >4.0.0,<6"
+hpack = ">=4.0,<5"
+hyperframe = ">=6.0,<7"
 
 [[package]]
 name = "hpack"
-version = "3.0.0"
+version = "4.0.0"
 description = "Pure-Python HPACK header compression"
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=3.6.1"
 
 [[package]]
 name = "http-ece"
@@ -327,28 +324,13 @@ python-versions = "*"
 [package.dependencies]
 cryptography = ">=2.5"
 
-[[package]]
-name = "hyper"
-version = "0.7.0"
-description = "HTTP/2 Client for Python"
-category = "main"
-optional = false
-python-versions = "*"
-
-[package.dependencies]
-h2 = ">=2.4,<3.0"
-hyperframe = ">=3.2,<4.0"
-
-[package.extras]
-fast = ["pycohttpparser"]
-
 [[package]]
 name = "hyperframe"
-version = "3.2.0"
+version = "6.0.1"
 description = "HTTP/2 framing layer for Python"
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=3.6.1"
 
 [[package]]
 name = "identify"
@@ -597,7 +579,7 @@ optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [package.extras]
-testing = ["docopt", "pytest (>=3.0.7)"]
+testing = ["pytest (>=3.0.7)", "docopt"]
 
 [[package]]
 name = "pexpect"
@@ -642,8 +624,8 @@ python-versions = ">=3.6"
 importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
 
 [package.extras]
-dev = ["pre-commit", "tox"]
-testing = ["pytest", "pytest-benchmark"]
+testing = ["pytest-benchmark", "pytest"]
+dev = ["tox", "pre-commit"]
 
 [[package]]
 name = "pre-commit"
@@ -765,16 +747,17 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "pyjwt"
-version = "1.7.1"
+version = "2.4.0"
 description = "JSON Web Token implementation in Python"
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=3.6"
 
 [package.extras]
-crypto = ["cryptography (>=1.4)"]
-flake8 = ["flake8", "flake8-import-order", "pep8-naming"]
-test = ["pytest (>=4.0.1,<5.0.0)", "pytest-cov (>=2.6.0,<3.0.0)", "pytest-runner (>=4.2,<5.0.0)"]
+crypto = ["cryptography (>=3.3.1)"]
+dev = ["sphinx", "sphinx-rtd-theme", "zope.interface", "cryptography (>=3.3.1)", "pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)", "mypy", "pre-commit"]
+docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
+tests = ["pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)"]
 
 [[package]]
 name = "pyopenssl"
@@ -1103,9 +1086,13 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.6.1"
-content-hash = "3e248119a43c3556cb8a5dbd4f3db0329a617d9a1291aff7d7ff5d1da2e22e15"
+content-hash = "299e7eb6edfbb16faeca3b0140e7ee6dc76dd2b910d60b026f354f5de4654665"
 
 [metadata.files]
+aioapns = [
+    {file = "aioapns-2.1-py3-none-any.whl", hash = "sha256:095a205ec08481a640ea85be1a85e4c1b26a43058a489ee7e48dcc13ab2d1d12"},
+    {file = "aioapns-2.1.tar.gz", hash = "sha256:2ce526910bc2514a84b8105abe80508526ceafc0097c89f86bbbc501f8666c99"},
+]
 aiohttp = [
     {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1ed0b6477896559f17b9eaeb6d38e07f7f9ffe40b9f0f9627ae8b9926ae260a8"},
     {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dadf3c307b31e0e61689cbf9e06be7a867c563d5a63ce9dca578f956609abf8"},
@@ -1184,10 +1171,6 @@ aiosignal = [
     {file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"},
     {file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"},
 ]
-apns2 = [
-    {file = "apns2-0.7.2-py2.py3-none-any.whl", hash = "sha256:f64a50181d0206a02943c835814a34fc1b1e12914931b74269a0f0fb4f39fd45"},
-    {file = "apns2-0.7.2.tar.gz", hash = "sha256:4f2dae8c608961d1768f734acb1d0809a60ac71a0cdcca60f46529b73f20fb34"},
-]
 appnope = [
     {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"},
     {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"},
@@ -1421,23 +1404,19 @@ frozenlist = [
     {file = "frozenlist-1.2.0.tar.gz", hash = "sha256:68201be60ac56aff972dc18085800b6ee07973c49103a8aba669dee3d71079de"},
 ]
 h2 = [
-    {file = "h2-2.6.2-py2.py3-none-any.whl", hash = "sha256:93cbd1013a2218539af05cdf9fc37b786655b93bbc94f5296b7dabd1c5cadf41"},
-    {file = "h2-2.6.2.tar.gz", hash = "sha256:af35878673c83a44afbc12b13ac91a489da2819b5dc1e11768f3c2406f740fe9"},
+    {file = "h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d"},
+    {file = "h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb"},
 ]
 hpack = [
-    {file = "hpack-3.0.0-py2.py3-none-any.whl", hash = "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89"},
-    {file = "hpack-3.0.0.tar.gz", hash = "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"},
+    {file = "hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"},
+    {file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"},
 ]
 http-ece = [
     {file = "http_ece-1.1.0.tar.gz", hash = "sha256:932ebc2fa7c216954c320a188ae9c1f04d01e67bec9cdce1bfbc912813b0b4f8"},
 ]
-hyper = [
-    {file = "hyper-0.7.0-py2.py3-none-any.whl", hash = "sha256:069514f54231fb7b5df2fb910a114663a83306d5296f588fffcb0a9be19407fc"},
-    {file = "hyper-0.7.0.tar.gz", hash = "sha256:12c82eacd122a659673484c1ea0d34576430afbe5aa6b8f63fe37fcb06a2458c"},
-]
 hyperframe = [
-    {file = "hyperframe-3.2.0-py2.py3-none-any.whl", hash = "sha256:4dcab11967482d400853b396d042038e4c492a15a5d2f57259e2b5f89a32f755"},
-    {file = "hyperframe-3.2.0.tar.gz", hash = "sha256:05f0e063e117c16fcdd13c12c93a4424a2c40668abfac3bb419a10f57698204e"},
+    {file = "hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15"},
+    {file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"},
 ]
 identify = [
     {file = "identify-2.4.4-py2.py3-none-any.whl", hash = "sha256:aa68609c7454dbcaae60a01ff6b8df1de9b39fe6e50b1f6107ec81dcda624aa6"},
@@ -1864,8 +1843,8 @@ pygments = [
     {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"},
 ]
 pyjwt = [
-    {file = "PyJWT-1.7.1-py2.py3-none-any.whl", hash = "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e"},
-    {file = "PyJWT-1.7.1.tar.gz", hash = "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96"},
+    {file = "PyJWT-2.4.0-py3-none-any.whl", hash = "sha256:72d1d253f32dbd4f5c88eaf1fdc62f3a19f676ccbadb9dbc5d07e951b2b26daf"},
+    {file = "PyJWT-2.4.0.tar.gz", hash = "sha256:d42908208c699b3b973cbeb01a969ba6a96c821eefb1c5bfe4c390c01d67abba"},
 ]
 pyopenssl = [
     {file = "pyOpenSSL-22.0.0-py2.py3-none-any.whl", hash = "sha256:ea252b38c87425b64116f808355e8da644ef9b07e429398bfece610f893ee2e0"},
diff --git a/pyproject.toml b/pyproject.toml
index f0823d5..a6c823f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,7 +15,6 @@ PyYAML = "^5.3.1"
 SQLAlchemy = "1.3.20"
 psycopg2-binary = "^2.8.6"
 pywebpush = "^1.11.0"
-apns2 = "^0.7.2"
 pytest = "^6.2.3"
 unittest = "^0.0"
 python-smail = "^0.9.0"
@@ -27,6 +26,7 @@ lxml = "^4.8.0"
 markdownify = "^0.11.2"
 watchpoints = "^0.2.5"
 pycryptodome = "^3.15.0"
+aioapns = "^2.1"
 
 [tool.poetry.dev-dependencies]
 pre-commit = "~2.9.2"
diff --git a/tests/unit/test_processors_safaripush_utils.py b/tests/unit/test_processors_safaripush_utils.py
index 047c6d1..061d149 100644
--- a/tests/unit/test_processors_safaripush_utils.py
+++ b/tests/unit/test_processors_safaripush_utils.py
@@ -1,7 +1,7 @@
 """Unit Tests for Utils in webpush."""
 import unittest
 
-from notifications_consumer.processors.safaripush.utils import create_message
+from notifications_consumer.processors.safaripush.client import ThreadSafeSafariPushClient
 
 
 class TestUtils(unittest.TestCase):
@@ -9,10 +9,20 @@ class TestUtils(unittest.TestCase):
 
     def test_validate_safari_message_body_decoded_correctly(self):
         """Tests correct bpdy decode htmlstrip+hemldecode."""
+        expected_message = {
+            "aps": {
+                "alert": {
+                    "title": "Test",
+                    "body": "This is a test we've got",
+                },
+                "url-args": ["cern.ch/news?today"],
+            }
+        }
         body = "<p>This is&nbsp;a test we&#39;ve got</p>"
-        expected_body = "This is a test we've got"
-        sp_message = create_message("Test", body, None, None)
-        self.assertTrue(sp_message.alert["body"] == expected_body)
+        url = "https://cern.ch/news?today"
+        request = ThreadSafeSafariPushClient._create_request("Test", body, url, None, "token")
+        self.assertTrue(request.message == expected_message)
+        self.assertTrue(request.device_token == "token")
 
 
 if __name__ == "__main__":
-- 
GitLab