diff --git a/kalyke/clients/live_activity.py b/kalyke/clients/live_activity.py index dfe8e24..288a18a 100644 --- a/kalyke/clients/live_activity.py +++ b/kalyke/clients/live_activity.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Any, Dict, Union from httpx import AsyncClient @@ -11,7 +12,7 @@ class LiveActivityClient(ApnsClient): async def send_message( self, device_token: str, - payload: LiveActivityPayload, + payload: Union[LiveActivityPayload, Dict[str, Any]], apns_config: LiveActivityApnsConfig, ) -> str: return await super().send_message( diff --git a/kalyke/exceptions.py b/kalyke/exceptions.py index 3dc45ee..7ca49f7 100644 --- a/kalyke/exceptions.py +++ b/kalyke/exceptions.py @@ -23,12 +23,12 @@ def __str__(self) -> str: class LiveActivityAttributesIsNotJSONSerializable(Exception): def __str__(self) -> str: - return "The attributes is not JSON serializable." + return "attributes is not JSON serializable." class LiveActivityContentStateIsNotJSONSerializable(Exception): def __str__(self) -> str: - return "The content state is not JSON serializable." + return "content-state is not JSON serializable." # https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/handling_notification_responses_from_apns#3394535 diff --git a/kalyke/models/live_activity_event.py b/kalyke/models/live_activity_event.py index 69a4e74..17a4fca 100644 --- a/kalyke/models/live_activity_event.py +++ b/kalyke/models/live_activity_event.py @@ -2,6 +2,6 @@ class LiveActivityEvent(Enum): - start: str = "start" - update: str = "update" - end: str = "end" + START: str = "start" + UPDATE: str = "update" + END: str = "end" diff --git a/kalyke/models/payload.py b/kalyke/models/payload.py index 2117e76..7f3a12b 100644 --- a/kalyke/models/payload.py +++ b/kalyke/models/payload.py @@ -79,20 +79,19 @@ class LiveActivityPayload(Payload): def __post_init__(self): if self.event is None: raise ValueError("event must be specified.") - elif self.event == LiveActivityEvent.start: + elif self.event == LiveActivityEvent.START: if self.attributes_type is None or self.attributes is None: raise ValueError( - """attributes_type and attributes must be specified when event is start. - Please see documentation: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification""" # noqa: E501 + "attributes_type and attributes must be specified when event is start.\nPlease see documentation: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification" # noqa: E501 ) try: _ = json.dumps(self.attributes) - except json.decoder.JSONDecodeError: + except TypeError: raise LiveActivityAttributesIsNotJSONSerializable() try: _ = json.dumps(self.content_state) - except json.decoder.JSONDecodeError: + except TypeError: raise LiveActivityContentStateIsNotJSONSerializable() super().__post_init__() diff --git a/tests/clients/live_activity/__init__.py b/tests/clients/live_activity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/clients/live_activity/conftest.py b/tests/clients/live_activity/conftest.py new file mode 100644 index 0000000..608f221 --- /dev/null +++ b/tests/clients/live_activity/conftest.py @@ -0,0 +1,8 @@ +from pathlib import Path + +import pytest + + +@pytest.fixture() +def auth_key_filepath() -> Path: + return Path(__file__).parent / "dummy.p8" diff --git a/tests/clients/live_activity/dummy.p8 b/tests/clients/live_activity/dummy.p8 new file mode 100644 index 0000000..a17c2bb --- /dev/null +++ b/tests/clients/live_activity/dummy.p8 @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgrcxc3odhJh+wcUbY +FQRIACPBtRdkAw2RkkoLEU//vVWhRANCAAS3E4nNyDsuBLssEHUDu/Eck4pUCKge +M/WbLSx83MmMWyv4ynD3z3xqjRptG1cGLGWuCXFZZ4+qPKKXixqShjaE +-----END PRIVATE KEY----- diff --git a/tests/clients/live_activity/test_init.py b/tests/clients/live_activity/test_init.py new file mode 100644 index 0000000..9a10038 --- /dev/null +++ b/tests/clients/live_activity/test_init.py @@ -0,0 +1,23 @@ +from kalyke import LiveActivityClient + + +def test_initialize_with_pathlib(auth_key_filepath): + client = LiveActivityClient( + use_sandbox=True, + team_id="DUMMY_TEAM_ID", + auth_key_id="DUMMY", + auth_key_filepath=auth_key_filepath, + ) + + assert isinstance(client, LiveActivityClient) + + +def test_initialize_with_str(auth_key_filepath): + client = LiveActivityClient( + use_sandbox=True, + team_id="DUMMY_TEAM_ID", + auth_key_id="DUMMY", + auth_key_filepath=str(auth_key_filepath), + ) + + assert isinstance(client, LiveActivityClient) diff --git a/tests/clients/live_activity/test_init_client.py b/tests/clients/live_activity/test_init_client.py new file mode 100644 index 0000000..a318fdb --- /dev/null +++ b/tests/clients/live_activity/test_init_client.py @@ -0,0 +1,20 @@ +from httpx import AsyncClient + +from kalyke import LiveActivityApnsConfig, LiveActivityClient + + +def test_exist_authorization_header(auth_key_filepath): + client = LiveActivityClient( + use_sandbox=True, + team_id="DUMMY_TEAM_ID", + auth_key_id="DUMMY", + auth_key_filepath=auth_key_filepath, + ) + actual_client = client._init_client( + apns_config=LiveActivityApnsConfig( + topic="com.example.App.push-type.liveactivity", + ), + ) + + assert isinstance(actual_client, AsyncClient) + assert actual_client.headers["authorization"].startswith("bearer ey") diff --git a/tests/clients/live_activity/test_make_authorization.py b/tests/clients/live_activity/test_make_authorization.py new file mode 100644 index 0000000..8cc01bb --- /dev/null +++ b/tests/clients/live_activity/test_make_authorization.py @@ -0,0 +1,53 @@ +import datetime +from pathlib import Path +from unittest.mock import MagicMock + +import jwt +import pytest + +from kalyke import LiveActivityClient + + +def test_success(auth_key_filepath): + datetime_mock = MagicMock(wraps=datetime.datetime) + datetime_mock.now.return_value = datetime.datetime(2022, 1, 10, 23, 6, 34) + + client = LiveActivityClient( + use_sandbox=True, + team_id="DUMMY_TEAM_ID", + auth_key_id="DUMMY", + auth_key_filepath=auth_key_filepath, + ) + token = client._make_authorization() + + expect = jwt.encode( + payload={ + "iss": "DUMMY_TEAM_ID", + "iat": str(int(datetime.datetime.now().timestamp())), + }, + key=auth_key_filepath.read_text(), + algorithm="ES256", + headers={ + "alg": "ES256", + "kid": "DUMMY", + }, + ) + + actual_payload = jwt.decode(jwt=token, options={"verify_signature": False}) + expect_payload = jwt.decode(jwt=expect, options={"verify_signature": False}) + assert actual_payload == expect_payload + assert jwt.get_unverified_header(jwt=token) == jwt.get_unverified_header(jwt=expect) + + +def test_file_not_found_error(): + client = LiveActivityClient( + use_sandbox=True, + team_id="DUMMY_TEAM_ID", + auth_key_id="DUMMY", + auth_key_filepath=Path("/") / "no_exist.p8", + ) + + with pytest.raises(FileNotFoundError) as e: + _ = client._make_authorization() + + assert str(e.value) == "[Errno 2] No such file or directory: '/no_exist.p8'" diff --git a/tests/clients/live_activity/test_send_message.py b/tests/clients/live_activity/test_send_message.py new file mode 100644 index 0000000..56f7d71 --- /dev/null +++ b/tests/clients/live_activity/test_send_message.py @@ -0,0 +1,86 @@ +import pytest + +from kalyke import LiveActivityApnsConfig, LiveActivityClient +from kalyke.exceptions import BadDeviceToken + + +@pytest.mark.asyncio +async def test_success(httpx_mock, auth_key_filepath): + httpx_mock.add_response( + status_code=200, + http_version="HTTP/2.0", + headers={ + "apns-id": "stub-apns-id", + }, + json={}, + ) + + client = LiveActivityClient( + use_sandbox=True, + team_id="DUMMY_TEAM", + auth_key_id="DUMMY", + auth_key_filepath=auth_key_filepath, + ) + apns_id = await client.send_message( + device_token="stub_device_token", + payload={ + "alert": "test alert", + }, + apns_config=LiveActivityApnsConfig( + topic="com.example.App.push-type.liveactivity", + ), + ) + + assert apns_id == "stub-apns-id" + + +@pytest.mark.asyncio +async def test_bad_device_token(httpx_mock, auth_key_filepath): + httpx_mock.add_response( + status_code=400, + http_version="HTTP/2.0", + json={ + "reason": "BadDeviceToken", + }, + ) + + client = LiveActivityClient( + use_sandbox=True, + team_id="DUMMY_TEAM_ID", + auth_key_id="DUMMY", + auth_key_filepath=auth_key_filepath, + ) + + with pytest.raises(BadDeviceToken) as e: + await client.send_message( + device_token="stub_device_token", + payload={ + "alert": "test alert", + }, + apns_config=LiveActivityApnsConfig( + topic="com.example.App.push-type.liveactivity", + ), + ) + + assert str(e.value) == str(BadDeviceToken(error={})) + + +@pytest.mark.asyncio +async def test_value_error(auth_key_filepath): + client = LiveActivityClient( + use_sandbox=True, + team_id="DUMMY_TEAM_ID", + auth_key_id="DUMMY", + auth_key_filepath=auth_key_filepath, + ) + + with pytest.raises(ValueError) as e: + await client.send_message( + device_token="stub_device_token", + payload=["test alert"], + apns_config=LiveActivityApnsConfig( + topic="com.example.App.push-type.liveactivity", + ), + ) + + assert str(e.value) == "Type of 'payload' must be specified by Payload or Dict[str, Any]." diff --git a/tests/models/payload/__init__.py b/tests/models/payload/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/models/payload/test_live_activity_payload.py b/tests/models/payload/test_live_activity_payload.py new file mode 100644 index 0000000..7e1c2b6 --- /dev/null +++ b/tests/models/payload/test_live_activity_payload.py @@ -0,0 +1,140 @@ +from datetime import datetime + +import pytest + +from kalyke import InterruptionLevel, LiveActivityEvent, LiveActivityPayload, PayloadAlert, exceptions + + +def test_live_activity_payload_event_is_not_event_value_error(): + with pytest.raises(ValueError) as e: + _ = LiveActivityPayload() + + assert str(e.value) == "event must be specified." + + +@pytest.mark.parametrize( + "attributes_type, attributes", + [ + ("AdventureAttributes", None), + (None, {"currentHealthLevel": 100, "eventDescription": "Adventure has begun!"}), + (None, None), + ], + ids=["attributes_type is None", "attributes is None", "attributes_type and attributes are None"], +) +def test_live_activity_payload_event_is_start_value_error(attributes_type, attributes): + with pytest.raises(ValueError) as e: + _ = LiveActivityPayload( + alert="this is alert", + event=LiveActivityEvent.START, + ) + + assert ( + str(e.value) + == "attributes_type and attributes must be specified when event is start.\nPlease see documentation: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification" # noqa: E501 + ) + + +def test_live_activity_payload_event_is_start_live_activity_attributes_is_not_json_serializable(): + with pytest.raises(exceptions.LiveActivityAttributesIsNotJSONSerializable) as e: + _ = LiveActivityPayload( + event=LiveActivityEvent.START, + attributes_type="AdventureAttributes", + attributes={"key": b"this is not JSON serializable."}, + ) + + assert str(e.value) == "attributes is not JSON serializable." + + +def test_live_activity_payload_event_is_update_live_activity_content_state_is_not_json_serializable(): + with pytest.raises(exceptions.LiveActivityContentStateIsNotJSONSerializable) as e: + _ = LiveActivityPayload( + event=LiveActivityEvent.UPDATE, + content_state={"key": b"this is not JSON serializable."}, + ) + + assert str(e.value) == "content-state is not JSON serializable." + + +def test_dict_with_live_activity_payload_alert(): + payload_alert = PayloadAlert( + title="this is title", + subtitle="this is subtitle", + body="this is body", + ) + now = datetime.now() + payload = LiveActivityPayload( + alert=payload_alert, + timestamp=now, + event=LiveActivityEvent.UPDATE, + ) + data = payload.dict() + + assert "aps" in data + aps = data["aps"] + assert aps["timestamp"] == int(now.timestamp()) + assert aps["event"] == "update" + assert aps["content-state"] == {} + assert aps["alert"]["title"] == "this is title" + assert aps["alert"]["subtitle"] == "this is subtitle" + assert aps["alert"]["body"] == "this is body" + + +def test_dict_without_live_activity_payload_alert(): + payload = LiveActivityPayload( + alert="this is alert", + badge=22, + sound="custom_sound", + thread_id="stub_thread_id", + category="stub_category", + content_available=True, + mutable_content=True, + target_content_identifier="stub_target_content_identifier", + interruption_level=InterruptionLevel.PASSIVE, + relevance_score=0.4, + filter_criteria="stub_filter_criteria", + custom={"stub_key": "stub_value"}, + event=LiveActivityEvent.UPDATE, + ) + data = payload.dict() + + assert "aps" in data + aps = data["aps"] + assert aps["event"] == "update" + assert aps["alert"] == "this is alert" + assert aps["badge"] == 22 + assert aps["sound"] == "custom_sound" + assert aps["thread-id"] == "stub_thread_id" + assert aps["category"] == "stub_category" + assert aps["content-available"] == 1 + assert aps["mutable-content"] == 1 + assert aps["target-content-identifier"] == "stub_target_content_identifier" + assert aps["interruption-level"] == "passive" + assert aps["relevance-score"] == 0.4 + assert aps["filter-criteria"] == "stub_filter_criteria" + assert data["stub_key"] == "stub_value" + + +def test_dict_not_relevance_score_out_of_range_exception(): + payload = LiveActivityPayload( + relevance_score=100, + event=LiveActivityEvent.UPDATE, + ) + data = payload.dict() + + assert "aps" in data + aps = data["aps"] + assert aps["relevance-score"] == 100 + + +def test_dict_without_all(): + now = datetime.now() + payload = LiveActivityPayload(timestamp=now, event=LiveActivityEvent.UPDATE) + data = payload.dict() + + assert "aps" in data + aps = data["aps"] + assert aps == { + "event": "update", + "timestamp": int(now.timestamp()), + "content-state": {}, + } diff --git a/tests/models/test_payload.py b/tests/models/payload/test_payload.py similarity index 100% rename from tests/models/test_payload.py rename to tests/models/payload/test_payload.py