From 3e5dac61adfafeddc090457de3cb73e9263a0d65 Mon Sep 17 00:00:00 2001 From: Arthur Borem Date: Mon, 15 Sep 2025 12:55:03 -0500 Subject: [PATCH 1/4] GroupMe fetch methods return vertical model instances --- src/pardner/services/groupme.py | 201 +++++++- src/pardner/verticals/chat_bot.py | 7 + tests/test_transfer_services/conftest.py | 11 + tests/test_transfer_services/test_groupme.py | 465 ++++++++++++++++--- tests/test_transfer_services/test_strava.py | 128 +++-- 5 files changed, 656 insertions(+), 156 deletions(-) diff --git a/src/pardner/services/groupme.py b/src/pardner/services/groupme.py index 433d170..c787bd5 100644 --- a/src/pardner/services/groupme.py +++ b/src/pardner/services/groupme.py @@ -1,4 +1,5 @@ import json +from collections import defaultdict from typing import Any, Iterable, Optional, override from urllib.parse import parse_qs, urlparse @@ -15,6 +16,7 @@ ConversationGroupVertical, Vertical, ) +from pardner.verticals.sub_verticals import AssociatedMediaSubVertical class GroupMeTransferService(BaseTransferService): @@ -135,32 +137,119 @@ def fetch_user_data(self, request_params: dict[str, Any] = {}) -> Any: self._user_id = user_data['id'] return user_data - def fetch_blocked_user_vertical(self, request_params: dict[str, Any] = {}) -> Any: + def parse_blocked_user_vertical(self, raw_data: Any) -> BlockedUserVertical | None: + """ + Given the response from the API request, creates a + :class:`BlockedUserVertical` model object, if possible. + + :param raw_data: the JSON representation of the data returned by the request. + + :returns: :class:`BlockedUserVertical` or ``None``, depending on whether it + was possible to extract data from the response + """ + if not isinstance(raw_data, dict): + return None + raw_data_dict = defaultdict(dict, raw_data) + return BlockedUserVertical( + service=self._service_name, + creator_user_id=raw_data_dict.get('user_id'), + data_owner_id=raw_data_dict.get('user_id', self._user_id), + blocked_user_id=raw_data_dict.get('blocked_user_id'), + created_at=raw_data_dict.get('created_at'), + ) + + def fetch_blocked_user_vertical( + self, request_params: dict[str, Any] = {} + ) -> tuple[list[BlockedUserVertical | None], Any]: """ Sends a GET request to fetch the users blocked by the authenticated user. - :returns: a JSON object with the result of the request. + :returns: two elements: the first, a list of :class:`BlockedUserVertical`s + or ``None``, if unable to parse; the second, the raw response from making the + request. """ blocked_users = self._fetch_resource_common('blocks', request_params) - if 'blocks' not in blocked_users: raise ValueError( f'Unexpected response format: {json.dumps(blocked_users, indent=2)}' ) + return [ + self.parse_blocked_user_vertical(blocked_dict) + for blocked_dict in blocked_users['blocks'] + ], blocked_users + + def parse_chat_bot_vertical(self, raw_data: Any) -> ChatBotVertical | None: + """ + Given the response from the API request, creates a + :class:`ChatBotVertical` model object, if possible. - return blocked_users['blocks'] + :param raw_data: the JSON representation of the data returned by the request. - def fetch_chat_bot_vertical(self, request_params: dict[str, Any] = {}) -> Any: + :returns: :class:`ChatBotVertical` or ``None``, depending on whether it + was possible to extract data from the response + """ + if not isinstance(raw_data, dict): + return None + raw_data_dict = defaultdict(dict, raw_data) + + user_id = raw_data_dict.get('user_id', self._user_id) + + return ChatBotVertical( + service=self._service_name, + service_object_id=raw_data_dict.get('bot_id'), + creator_user_id=user_id, + data_owner_id=user_id, + name=raw_data_dict.get('name'), + conversation_group_id=raw_data_dict.get('group_id'), + ) + + def fetch_chat_bot_vertical( + self, request_params: dict[str, Any] = {} + ) -> tuple[list[ChatBotVertical | None], Any]: """ Sends a GET request to fetch the chat bots created by the authenticated user. - :returns: a JSON object with the result of the request. + :returns: two elements: the first, a list of :class:`ChatBotVertical`s + or ``None``, if unable to parse; the second, the raw response from making the + request. """ - return self._fetch_resource_common('bots', request_params) + bots_response = self._fetch_resource_common('bots', request_params) + if not isinstance(bots_response, list): + raise ValueError( + f'Unexpected response format: {json.dumps(bots_response, indent=2)}' + ) + return [ + self.parse_chat_bot_vertical(chat_bot_data) + for chat_bot_data in bots_response + ], bots_response + + def parse_conversation_direct_vertical( + self, raw_data: Any + ) -> ConversationDirectVertical | None: + """ + Given the response from the API request, creates a + :class:`ConversationDirectVertical` model object, if possible. + + :param raw_data: the JSON representation of the data returned by the request. + + :returns: :class:`ConversationDirectVertical` or ``None``, depending on + whether it was possible to extract data from the response + """ + if not isinstance(raw_data, dict): + return None + raw_data_dict = defaultdict(dict, raw_data) + return ConversationDirectVertical( + service=self._service_name, + service_object_id=raw_data_dict.get('id'), + data_owner_id=self._user_id, + member_user_ids=[self._user_id, raw_data_dict['other_user'].get('id')], + messages_count=raw_data_dict.get('messages_count'), + created_at=raw_data_dict.get('created_at'), + ) def fetch_conversation_direct_vertical( self, request_params: dict[str, Any] = {}, count: int = 10 - ) -> Any: + ) -> tuple[list[ConversationDirectVertical | None], Any]: """ Sends a GET request to fetch the conversations the authenticated user is a part of with only one other member (i.e., a direct message). The response will @@ -169,15 +258,72 @@ def fetch_conversation_direct_vertical( :param count: the number of conversations to fetch. Defaults to 10. - :returns: a JSON object with the result of the request. + :returns: two elements: the first, a list of + :class:`ConversationDirectVertical`s or ``None``, if unable to parse; the + second, the raw response from making the request. """ - if count <= 10: - return self._fetch_resource_common( - 'chats', params={**request_params, 'per_page': count} + if count > 10: + raise UnsupportedRequestException( + self._service_name, + 'can only make a request for at most 10 direct conversations at a time.', ) - raise UnsupportedRequestException( - self._service_name, - 'can only make a request for at most 10 direct conversations at a time.', + conversation_direct_raw_response = self._fetch_resource_common( + 'chats', params={**request_params, 'per_page': count} + ) + if not isinstance(conversation_direct_raw_response, list): + raise ValueError( + 'Unexpected response format. Expected list, ' + f'got: {json.dumps(conversation_direct_raw_response, indent=2)}' + ) + return [ + self.parse_conversation_direct_vertical(conversation_direct_data) + for conversation_direct_data in conversation_direct_raw_response + ], conversation_direct_raw_response + + def parse_conversation_group_vertical( + self, raw_data: Any + ) -> ConversationGroupVertical | None: + """ + Given the response from the API request, creates a + :class:`ConversationGroupVertical` model object, if possible. + + :param raw_data: the JSON representation of the data returned by the request. + + :returns: :class:`ConversationGroupVertical` or ``None``, depending on + whether it was possible to extract data from the response + """ + if not isinstance(raw_data, dict): + return None + raw_data_dict = defaultdict(dict, raw_data) + + members_list = raw_data_dict.get('members', []) + member_user_ids = [] + for member in members_list: + if isinstance(member, dict) and 'user_id' in member: + member_user_ids.append(member['user_id']) + + associated_media = [] + image_url = raw_data_dict.get('image_url', None) + if image_url: + associated_media = [AssociatedMediaSubVertical(image_url=image_url)] + + is_private = None + conversation_type = raw_data_dict.get('type') + if isinstance(conversation_type, str): + is_private = conversation_type == 'private' + + return ConversationGroupVertical( + service=self._service_name, + service_object_id=raw_data_dict.get('id'), + data_owner_id=self._user_id, + creator_user_id=raw_data_dict.get('creator_user_id'), + title=raw_data_dict.get('name'), + member_user_ids=member_user_ids, + members_count=len(members_list), + messages_count=raw_data_dict['messages'].get('count'), + associated_media=associated_media, + created_at=raw_data_dict.get('created_at'), + is_private=is_private, ) def fetch_conversation_group_vertical( @@ -190,13 +336,24 @@ def fetch_conversation_group_vertical( :param count: the number of conversations to fetch. Defaults to 10. - :returns: a JSON object with the result of the request. + :returns: two elements: the first, a list of + :class:`ConversationGroupVertical`s or ``None``, if unable to parse; the + second, the raw response from making the request. """ - if count <= 10: - return self._fetch_resource_common( - 'groups', params={**request_params, 'per_page': count} + if count > 10: + raise UnsupportedRequestException( + self._service_name, + 'can only make a request for at most 10 group conversations at a time.', ) - raise UnsupportedRequestException( - self._service_name, - 'can only make a request for at most 10 group conversations at a time.', + conversation_group_raw_response = self._fetch_resource_common( + 'groups', params={**request_params, 'per_page': count} ) + if not isinstance(conversation_group_raw_response, list): + raise ValueError( + 'Unexpected response format. Expected list, ' + f'got: {json.dumps(conversation_group_raw_response, indent=2)}' + ) + return [ + self.parse_conversation_group_vertical(conversation_group_data) + for conversation_group_data in conversation_group_raw_response + ], conversation_group_raw_response diff --git a/src/pardner/verticals/chat_bot.py b/src/pardner/verticals/chat_bot.py index c1f2c10..c7ecb30 100644 --- a/src/pardner/verticals/chat_bot.py +++ b/src/pardner/verticals/chat_bot.py @@ -1,3 +1,5 @@ +from pydantic import Field + from pardner.verticals import BaseVertical @@ -5,3 +7,8 @@ class ChatBotVertical(BaseVertical): """An instance of a chatbot created by ``creator_user_id``.""" vertical_name: str = 'chat_bot' + + name: str | None = None + conversation_group_id: str | None = Field( + description='Generated by the service.', default=None + ) diff --git a/tests/test_transfer_services/conftest.py b/tests/test_transfer_services/conftest.py index 5456323..41afe7e 100644 --- a/tests/test_transfer_services/conftest.py +++ b/tests/test_transfer_services/conftest.py @@ -123,3 +123,14 @@ def mock_oauth2_session_get(mocker, response_object=None): oauth2_session_get = mocker.patch.object(OAuth2Session, 'get', autospec=True) oauth2_session_get.return_value = response_object return oauth2_session_get + + +def dump_and_filter_model_objs(model_objs): + """ + ``pardner_object_id`` is auto-generated by pardner so we don't need to compare + it when testing. + """ + model_obj_dumps = [model_obj.model_dump() for model_obj in model_objs] + for model_obj_dump in model_obj_dumps: + del model_obj_dump['pardner_object_id'] + return model_obj_dumps diff --git a/tests/test_transfer_services/test_groupme.py b/tests/test_transfer_services/test_groupme.py index 0c16087..0e24ede 100644 --- a/tests/test_transfer_services/test_groupme.py +++ b/tests/test_transfer_services/test_groupme.py @@ -1,7 +1,13 @@ +from datetime import datetime, timezone + import pytest +from pydantic import AnyHttpUrl from pardner.exceptions import UnsupportedRequestException -from tests.test_transfer_services.conftest import mock_oauth2_session_get +from tests.test_transfer_services.conftest import ( + dump_and_filter_model_objs, + mock_oauth2_session_get, +) USER_ID = 'fake_user_id' FAKE_LIST_RESPONSE = ['fake', 'list', 'response'] @@ -32,6 +38,20 @@ def test_fetch_token(mock_oauth2_session_request, mock_groupme_transfer_service) ) +def test__fetch_resource_common_raises_exception(mock_groupme_transfer_service, mocker): + response_object = mocker.MagicMock() + response_object.status_code = 200 + response_object.json.return_value = {'response': {'id': USER_ID}} + oauth2_session_get = mock_oauth2_session_get(mocker, response_object) + + mock_groupme_transfer_service._fetch_resource_common( + 'https://api.groupme.com/v3/fake' + ) + + assert oauth2_session_get.call_count == 2 + assert mock_groupme_transfer_service._user_id == USER_ID + + def test_fetch_user_data_raises_no_token(mock_groupme_transfer_service): mock_groupme_transfer_service._oAuth2Session.token = {} with pytest.raises(UnsupportedRequestException): @@ -49,66 +69,113 @@ def test_fetch_user_data(mocker, mock_groupme_transfer_service): assert oauth2_session_get.call_args.args[1] == 'https://api.groupme.com/v3/users/me' -@pytest.mark.parametrize( - [ - 'method_name', - 'json_response', - 'expected_path', - 'expected_params', - 'expected_return_val', - ], - [ - ( - 'fetch_blocked_user_vertical', - {'response': {'blocks': FAKE_LIST_RESPONSE}}, - 'https://api.groupme.com/v3/blocks', - {}, - FAKE_LIST_RESPONSE, - ), - ( - 'fetch_chat_bot_vertical', - {'response': FAKE_LIST_RESPONSE}, - 'https://api.groupme.com/v3/bots', - {}, - FAKE_LIST_RESPONSE, - ), - ( - 'fetch_conversation_direct_vertical', - {'response': FAKE_LIST_RESPONSE}, - 'https://api.groupme.com/v3/chats', - {'per_page': 10}, - FAKE_LIST_RESPONSE, - ), - ( - 'fetch_conversation_group_vertical', - {'response': FAKE_LIST_RESPONSE}, - 'https://api.groupme.com/v3/groups', - {'per_page': 10}, - FAKE_LIST_RESPONSE, - ), - ], -) -def test_fetch_vertical( - method_name, - json_response, - expected_path, - expected_params, - expected_return_val, - mock_groupme_transfer_service, - mocker, -): - full_expected_params = {'user': USER_ID, 'token': TOKEN, **expected_params} - +def test_fetch_blocked_users_raises_exception(mock_groupme_transfer_service, mocker): mock_groupme_transfer_service._user_id = USER_ID + response_object = mocker.MagicMock() + response_object.status_code = 200 + response_object.json.return_value = {'response': {'no_blocks': []}} + mock_oauth2_session_get(mocker, response_object) + with pytest.raises(ValueError): + mock_groupme_transfer_service.fetch_blocked_user_vertical() + +def test_fetch_blocked_user_vertical(mock_groupme_transfer_service, mocker): + mock_groupme_transfer_service._user_id = USER_ID response_object = mocker.MagicMock() - response_object.json.return_value = json_response + # adapted from + # https://dev.groupme.com/docs/v3#blocks_index + response_object.json.return_value = { + 'response': { + 'blocks': [ + { + 'user_id': USER_ID, + 'blocked_user_id': '1234567890', + 'created_at': 1302623328, + }, + {'blocked_user_id': '12345678901', 'created_at': 1302623348}, + ] + } + } + oauth2_session_get = mock_oauth2_session_get(mocker, response_object) + model_objs, _ = mock_groupme_transfer_service.fetch_blocked_user_vertical() + model_obj_dumps = dump_and_filter_model_objs(model_objs) + + assert oauth2_session_get.call_args.args[1] == 'https://api.groupme.com/v3/blocks' + base_model_dict = { + 'service_object_id': None, + 'creator_user_id': None, + 'data_owner_id': USER_ID, + 'service': 'GroupMe', + 'url': None, + 'vertical_name': 'blocked_user', + } + + assert model_obj_dumps == [ + { + **base_model_dict, + 'creator_user_id': USER_ID, + 'created_at': datetime(2011, 4, 12, 15, 48, 48, tzinfo=timezone.utc), + 'blocked_user_id': '1234567890', + }, + { + **base_model_dict, + 'created_at': datetime(2011, 4, 12, 15, 49, 8, tzinfo=timezone.utc), + 'blocked_user_id': '12345678901', + }, + ] + + +def test_fetch_chat_bot_vertical(mock_groupme_transfer_service, mocker): + mock_groupme_transfer_service._user_id = USER_ID + response_object = mocker.MagicMock() + # adapted from + # https://dev.groupme.com/docs/v3#bots_index + response_object.json.return_value = { + 'response': [ + { + 'bot_id': '1234567890', + 'group_id': '1234567890', + 'name': 'hal9000', + 'avatar_url': 'https://i.groupme.com/123456789', + 'callback_url': 'https://example.com/bots/callback', + 'dm_notification': False, + 'active': True, + }, + { + 'bot_id': '123', + 'name': 'hal9001', + 'avatar_url': 'https://i.groupme.com/123456789', + 'callback_url': 'https://example.com/bots/callback', + }, + ] + } oauth2_session_get = mock_oauth2_session_get(mocker, response_object) + model_objs, _ = mock_groupme_transfer_service.fetch_chat_bot_vertical() + + assert oauth2_session_get.call_args.args[1] == 'https://api.groupme.com/v3/bots' + + model_obj_dumps = dump_and_filter_model_objs(model_objs) + + base_model_dict = { + 'creator_user_id': USER_ID, + 'data_owner_id': USER_ID, + 'service': 'GroupMe', + 'vertical_name': 'chat_bot', + 'created_at': None, + 'url': None, + 'conversation_group_id': None, + } - assert getattr(mock_groupme_transfer_service, method_name)() == expected_return_val - assert oauth2_session_get.call_args.args[1] == expected_path - assert oauth2_session_get.call_args.kwargs.get('params') == full_expected_params + assert model_obj_dumps == [ + { + **base_model_dict, + 'service_object_id': '1234567890', + 'name': 'hal9000', + 'conversation_group_id': '1234567890', + }, + {**base_model_dict, 'service_object_id': '123', 'name': 'hal9001'}, + ] @pytest.mark.parametrize( @@ -122,25 +189,285 @@ def test_fetch_conversations_raises_exception( getattr(mock_groupme_transfer_service, method_name)(count=11) -def test__fetch_resource_common_raises_exception(mock_groupme_transfer_service, mocker): +def test_fetch_conversation_direct_vertical(mock_groupme_transfer_service, mocker): + mock_groupme_transfer_service._user_id = USER_ID response_object = mocker.MagicMock() - response_object.status_code = 200 - response_object.json.return_value = {'response': {'id': USER_ID}} + # adapted from + # https://dev.groupme.com/docs/v3#chats_index + response_object.json.return_value = { + 'response': [ + { + 'created_at': 1352299338, + 'updated_at': 1352299338, + 'last_message': { + 'attachments': [], + 'avatar_url': 'https://i.groupme.com/200x200.jpeg.abcdef', + 'conversation_id': '12345+67890', + 'created_at': 1352299338, + 'favorited_by': [], + 'id': '1234567890', + 'name': 'John Doe', + 'recipient_id': '67890', + 'sender_id': '12345', + 'sender_type': 'user', + 'source_guid': 'GUID', + 'text': 'Hello world', + 'user_id': '12345', + }, + 'messages_count': 10, + 'other_user': { + 'avatar_url': 'https://i.groupme.com/200x200.jpeg.abcdef', + 'id': 12345, + 'name': 'John Doe', + }, + }, + { + 'created_at': 1668785830, + 'last_message': { + 'attachments': [ + { + 'type': 'image', + 'url': 'https://i.groupme.com/351x672.png.101010', + } + ], + 'avatar_url': None, + 'conversation_id': '101010+202020', + 'created_at': 1668785830, + 'favorited_by': [], + 'id': '166116611', + 'name': 'Gabriel', + 'recipient_id': '101010', + 'sender_id': '202020', + 'sender_type': 'user', + 'source_guid': 'caabcdef', + 'text': None, + 'user_id': '202020', + 'pinned_at': None, + 'pinned_by': '', + }, + 'messages_count': 1, + 'other_user': { + 'avatar_url': 'https://i.groupme.com/512x512.jpeg.1010', + 'id': '101010', + 'name': 'Gabriel', + }, + 'updated_at': 1668785830, + 'message_deletion_period': 2147483647, + 'message_deletion_mode': ['sender'], + 'requires_approval': False, + 'unread_count': None, + 'last_read_message_id': None, + 'last_read_at': None, + 'message_edit_period': 15, + }, + ] + } oauth2_session_get = mock_oauth2_session_get(mocker, response_object) + model_objs, _ = mock_groupme_transfer_service.fetch_conversation_direct_vertical() - mock_groupme_transfer_service._fetch_resource_common( - 'https://api.groupme.com/v3/fake' - ) + assert oauth2_session_get.call_args.args[1] == 'https://api.groupme.com/v3/chats' - assert oauth2_session_get.call_count == 2 - assert mock_groupme_transfer_service._user_id == USER_ID + model_obj_dumps = dump_and_filter_model_objs(model_objs) + base_model_dict = { + 'service_object_id': None, + 'creator_user_id': None, + 'data_owner_id': USER_ID, + 'service': 'GroupMe', + 'vertical_name': 'conversation_direct', + 'url': None, + 'is_group_conversation': False, + 'abstract': None, + 'associated_media': [], + 'is_private': None, + 'members_count': 2, + 'title': None, + } -def test_fetch_blocked_users_raises_exception(mock_groupme_transfer_service, mocker): + assert model_obj_dumps == [ + { + **base_model_dict, + 'created_at': datetime(2012, 11, 7, 14, 42, 18, tzinfo=timezone.utc), + 'member_user_ids': [USER_ID, '12345'], + 'messages_count': 10, + }, + { + **base_model_dict, + 'created_at': datetime(2022, 11, 18, 15, 37, 10, tzinfo=timezone.utc), + 'member_user_ids': [USER_ID, '101010'], + 'messages_count': 1, + }, + ] + + +def test_fetch_conversation_group_vertical(mock_groupme_transfer_service, mocker): mock_groupme_transfer_service._user_id = USER_ID response_object = mocker.MagicMock() - response_object.status_code = 200 - response_object.json.return_value = {'response': {'no_blocks': []}} - mock_oauth2_session_get(mocker, response_object) - with pytest.raises(ValueError): - mock_groupme_transfer_service.fetch_blocked_user_vertical() + # adapted from + # https://dev.groupme.com/docs/v3#groups_index + response_object.json.return_value = { + 'response': [ + { + 'id': '1234567890', + 'name': 'Family', + 'type': 'private', + 'description': 'Coolest Family Ever', + 'image_url': 'https://i.groupme.com/123456789', + 'creator_user_id': USER_ID, + 'created_at': 1302623328, + 'updated_at': 1302623328, + 'members': [ + { + 'user_id': USER_ID, + 'nickname': 'Jane', + 'muted': False, + 'image_url': 'https://i.groupme.com/123456789', + } + ], + 'share_url': 'https://groupme.com/join_group/1234567890/SHARE_TOKEN', + 'messages': { + 'count': 100, + 'last_message_id': '1234567890', + 'last_message_created_at': 1302623328, + 'preview': { + 'nickname': 'Jane', + 'text': 'Hello world', + 'image_url': 'https://i.groupme.com/123456789', + 'attachments': [ + {'type': 'image', 'url': 'https://i.groupme.com/123456789'}, + {'type': 'image', 'url': 'https://i.groupme.com/123456789'}, + { + 'type': 'location', + 'lat': '40.738206', + 'lng': '-73.993285', + 'name': 'GroupMe HQ', + }, + {'type': 'split', 'token': 'SPLIT_TOKEN'}, + { + 'type': 'emoji', + 'placeholder': '☃', + 'charmap': [[1, 42], [2, 34]], + }, + ], + }, + }, + }, + { + 'id': '111111', + 'group_id': '111111', + 'name': 'Second Group', + 'phone_number': '+199999999999', + 'type': 'closed', + 'description': "Hey y'all!", + 'image_url': None, + 'creator_user_id': 'u222222', + 'created_at': 1554220666, + 'updated_at': 1579220489, + 'muted_until': None, + 'messages': { + 'count': 380, + 'last_message_id': '101010', + 'last_message_created_at': 1579220489, + 'last_message_updated_at': 1579220489, + 'preview': { + 'nickname': 'Kenneth', + 'text': 'these are cool', + 'image_url': 'https://i.groupme.com/1818x1228.jpeg', + 'attachments': [], + }, + }, + 'max_members': 5000, + 'theme_name': None, + 'like_icon': None, + 'requires_approval': False, + 'show_join_question': False, + 'join_question': None, + 'message_deletion_period': 2147483647, + 'message_deletion_mode': ['admin', 'sender'], + 'message_edit_period': 15, + 'children_count': 0, + 'share_url': None, + 'share_qr_code_url': None, + 'directories': None, + 'members': [ + { + 'user_id': USER_ID, + 'nickname': 'Member1', + 'image_url': 'https://i.groupme.com/750x750.jpeg', + 'id': USER_ID, + 'muted': False, + 'autokicked': False, + 'roles': ['admin', 'owner'], + 'name': 'Member1', + }, + { + 'user_id': 'u222222', + 'nickname': 'Member2', + 'image_url': 'https://i.groupme.com/960x720.jpeg', + 'id': 'u222222', + 'muted': False, + 'autokicked': False, + 'roles': ['user'], + 'name': 'Member2', + }, + ], + 'members_count': 2, + 'locations': None, + 'visibility': None, + 'category_ids': None, + 'active_call_participants': None, + 'unread_count': None, + 'last_read_message_id': None, + 'last_read_at': None, + }, + ] + } + oauth2_session_get = mock_oauth2_session_get(mocker, response_object) + model_objs, _ = mock_groupme_transfer_service.fetch_conversation_group_vertical() + + assert oauth2_session_get.call_args.args[1] == 'https://api.groupme.com/v3/groups' + + model_obj_dumps = dump_and_filter_model_objs(model_objs) + + base_model_dict = { + 'creator_user_id': USER_ID, + 'data_owner_id': USER_ID, + 'service': 'GroupMe', + 'vertical_name': 'conversation_group', + 'url': None, + 'is_group_conversation': True, + 'abstract': None, + 'associated_media': [], + 'is_private': None, + } + + assert model_obj_dumps == [ + { + **base_model_dict, + 'service_object_id': '1234567890', + 'created_at': datetime(2011, 4, 12, 15, 48, 48, tzinfo=timezone.utc), + 'associated_media': [ + { + 'audio_url': None, + 'image_url': AnyHttpUrl('https://i.groupme.com/123456789'), + 'video_url': None, + } + ], + 'is_private': True, + 'member_user_ids': [USER_ID], + 'members_count': 1, + 'messages_count': 100, + 'title': 'Family', + }, + { + **base_model_dict, + 'creator_user_id': 'u222222', + 'service_object_id': '111111', + 'created_at': datetime(2019, 4, 2, 15, 57, 46, tzinfo=timezone.utc), + 'is_private': False, + 'member_user_ids': [USER_ID, 'u222222'], + 'members_count': 2, + 'messages_count': 380, + 'title': 'Second Group', + }, + ] diff --git a/tests/test_transfer_services/test_strava.py b/tests/test_transfer_services/test_strava.py index 007a1da..b725a28 100644 --- a/tests/test_transfer_services/test_strava.py +++ b/tests/test_transfer_services/test_strava.py @@ -6,7 +6,11 @@ from pardner.exceptions import UnsupportedRequestException, UnsupportedVerticalException from pardner.verticals.physical_activity import PhysicalActivityVertical -from tests.test_transfer_services.conftest import NewVertical, mock_oauth2_session_get +from tests.test_transfer_services.conftest import ( + NewVertical, + dump_and_filter_model_objs, + mock_oauth2_session_get, +) def test_scope(mock_strava_transfer_service): @@ -51,7 +55,7 @@ def test_fetch_physical_activity_vertical(mocker, mock_strava_transfer_service): 'elapsed_time': 4500, 'total_elevation_gain': 0, 'type': 'Ride', - 'sport_type': 'MountainBikeRide', + 'sport_type': 'Hike', 'workout_type': None, 'id': 154504250376823, 'external_id': 'garmin_push_12345678987654321', @@ -166,86 +170,80 @@ def test_fetch_physical_activity_vertical(mocker, mock_strava_transfer_service): oauth2_session_get = mock_oauth2_session_get(mocker, response_object) model_objs, _ = mock_strava_transfer_service.fetch_physical_activity_vertical() - model_obj1, model_obj2 = model_objs + model_obj_dumps = dump_and_filter_model_objs(model_objs) assert ( oauth2_session_get.call_args.args[1] == 'https://www.strava.com/api/v3/athlete/activities' ) - model_obj1_json = model_obj1.model_dump() - del model_obj1_json['pardner_object_id'] - - assert model_obj1_json == { - 'service_object_id': '154504250376823', - 'creator_user_id': '134815', - 'data_owner_id': '134815', + base_model_dict = { 'service': 'Strava', 'vertical_name': 'physical_activity', - 'created_at': datetime.datetime(2018, 5, 2, 12, 15, 9), - 'url': AnyHttpUrl('https://www.strava.com/activities/154504250376823'), - 'abstract': None, - 'associated_media': [ - { - 'audio_url': None, - 'image_url': AnyHttpUrl('https://url1.com/'), - 'video_url': None, - }, - { - 'audio_url': None, - 'image_url': AnyHttpUrl('https://url2.com/'), - 'video_url': None, - }, - ], - 'interaction_count': 4, - 'keywords': [], - 'shared_content': [], - 'status': 'restricted', - 'text': 'mock description', - 'title': 'Happy Friday', - 'activity_type': 'MountainBikeRide', - 'distance': 24931.4, - 'elevation_high': None, - 'elevation_low': None, - 'kilocalories': 870.2, - 'max_speed': 11.0, - 'start_datetime': datetime.datetime(2018, 5, 2, 12, 15, 9), - 'start_latitude': 41.41, - 'start_longitude': -41.41, - 'end_datetime': datetime.datetime(2018, 5, 2, 13, 30, 9), - 'end_latitude': 42.42, - 'end_longitude': -42.42, - } - - model_obj2_json = model_obj2.model_dump() - del model_obj2_json['pardner_object_id'] - - assert model_obj2_json == { - 'service_object_id': '1234567809', - 'creator_user_id': '167560', - 'data_owner_id': '167560', - 'service': 'Strava', - 'vertical_name': 'physical_activity', - 'created_at': datetime.datetime(2018, 4, 30, 12, 35, 51), - 'url': AnyHttpUrl('https://www.strava.com/activities/1234567809'), 'abstract': None, 'associated_media': [], 'interaction_count': 4, 'keywords': [], 'shared_content': [], - 'status': 'public', 'text': None, - 'title': 'Bondcliff', - 'activity_type': 'MountainBikeRide', - 'distance': 23676.5, - 'elevation_high': 182.5, - 'elevation_low': 179.9, + 'elevation_high': None, + 'elevation_low': None, 'kilocalories': None, - 'max_speed': 8.8, - 'start_datetime': datetime.datetime(2018, 4, 30, 12, 35, 51), 'start_latitude': None, 'start_longitude': None, - 'end_datetime': datetime.datetime(2018, 4, 30, 14, 5, 51), 'end_latitude': None, 'end_longitude': None, } + + assert model_obj_dumps == [ + { + **base_model_dict, + 'service_object_id': '154504250376823', + 'creator_user_id': '134815', + 'data_owner_id': '134815', + 'created_at': datetime.datetime(2018, 5, 2, 12, 15, 9), + 'url': AnyHttpUrl('https://www.strava.com/activities/154504250376823'), + 'associated_media': [ + { + 'audio_url': None, + 'image_url': AnyHttpUrl('https://url1.com/'), + 'video_url': None, + }, + { + 'audio_url': None, + 'image_url': AnyHttpUrl('https://url2.com/'), + 'video_url': None, + }, + ], + 'status': 'restricted', + 'text': 'mock description', + 'title': 'Happy Friday', + 'activity_type': 'Hike', + 'distance': 24931.4, + 'kilocalories': 870.2, + 'max_speed': 11.0, + 'start_datetime': datetime.datetime(2018, 5, 2, 12, 15, 9), + 'start_latitude': 41.41, + 'start_longitude': -41.41, + 'end_datetime': datetime.datetime(2018, 5, 2, 13, 30, 9), + 'end_latitude': 42.42, + 'end_longitude': -42.42, + }, + { + **base_model_dict, + 'service_object_id': '1234567809', + 'creator_user_id': '167560', + 'data_owner_id': '167560', + 'created_at': datetime.datetime(2018, 4, 30, 12, 35, 51), + 'url': AnyHttpUrl('https://www.strava.com/activities/1234567809'), + 'status': 'public', + 'title': 'Bondcliff', + 'activity_type': 'MountainBikeRide', + 'distance': 23676.5, + 'elevation_high': 182.5, + 'elevation_low': 179.9, + 'max_speed': 8.8, + 'start_datetime': datetime.datetime(2018, 4, 30, 12, 35, 51), + 'end_datetime': datetime.datetime(2018, 4, 30, 14, 5, 51), + }, + ] From 318e5049fe77f9bf8b080131c117683def5d0369 Mon Sep 17 00:00:00 2001 From: Arthur Borem Date: Mon, 15 Sep 2025 13:00:31 -0500 Subject: [PATCH 2/4] updates readme --- README.md | 10 +++++----- src/pardner/services/groupme.py | 2 -- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1a3b8d0..8aac640 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,12 @@ additional ✓ (as in the [Quickstart example](#quickstart-example) above). | | GroupMe | Strava | Tumblr | | ------------------ | ------- | ------ | ------ | -| BlockedUser | ✓ | | 👀 | -| ChatBot | ✓ | | | -| ConversationDirect | ✓ | | | -| ConversationGroup | ✓ | | | +| BlockedUser | ✓✓ | | 👀 | +| ChatBot | ✓✓ | | | +| ConversationDirect | ✓✓ | | | +| ConversationGroup | ✓✓ | | | | Message | | | | -| PhysicalActivity | | ✓ | | +| PhysicalActivity | | ✓✓ | | | SocialPosting | | ✓✓ | ✓ | The transfer services are defined in diff --git a/src/pardner/services/groupme.py b/src/pardner/services/groupme.py index c787bd5..f7d9bd4 100644 --- a/src/pardner/services/groupme.py +++ b/src/pardner/services/groupme.py @@ -191,9 +191,7 @@ def parse_chat_bot_vertical(self, raw_data: Any) -> ChatBotVertical | None: if not isinstance(raw_data, dict): return None raw_data_dict = defaultdict(dict, raw_data) - user_id = raw_data_dict.get('user_id', self._user_id) - return ChatBotVertical( service=self._service_name, service_object_id=raw_data_dict.get('bot_id'), From c60ec532087e077c00e373603a802419720d1386 Mon Sep 17 00:00:00 2001 From: Arthur Borem Date: Mon, 15 Sep 2025 13:02:31 -0500 Subject: [PATCH 3/4] fixed extra argument in chat bot test --- src/pardner/services/groupme.py | 1 - src/pardner/verticals/chat_bot.py | 5 ----- tests/test_transfer_services/test_groupme.py | 8 +------- 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/src/pardner/services/groupme.py b/src/pardner/services/groupme.py index f7d9bd4..222d542 100644 --- a/src/pardner/services/groupme.py +++ b/src/pardner/services/groupme.py @@ -198,7 +198,6 @@ def parse_chat_bot_vertical(self, raw_data: Any) -> ChatBotVertical | None: creator_user_id=user_id, data_owner_id=user_id, name=raw_data_dict.get('name'), - conversation_group_id=raw_data_dict.get('group_id'), ) def fetch_chat_bot_vertical( diff --git a/src/pardner/verticals/chat_bot.py b/src/pardner/verticals/chat_bot.py index c7ecb30..76cdcd0 100644 --- a/src/pardner/verticals/chat_bot.py +++ b/src/pardner/verticals/chat_bot.py @@ -1,5 +1,3 @@ -from pydantic import Field - from pardner.verticals import BaseVertical @@ -9,6 +7,3 @@ class ChatBotVertical(BaseVertical): vertical_name: str = 'chat_bot' name: str | None = None - conversation_group_id: str | None = Field( - description='Generated by the service.', default=None - ) diff --git a/tests/test_transfer_services/test_groupme.py b/tests/test_transfer_services/test_groupme.py index 0e24ede..da62c7a 100644 --- a/tests/test_transfer_services/test_groupme.py +++ b/tests/test_transfer_services/test_groupme.py @@ -164,16 +164,10 @@ def test_fetch_chat_bot_vertical(mock_groupme_transfer_service, mocker): 'vertical_name': 'chat_bot', 'created_at': None, 'url': None, - 'conversation_group_id': None, } assert model_obj_dumps == [ - { - **base_model_dict, - 'service_object_id': '1234567890', - 'name': 'hal9000', - 'conversation_group_id': '1234567890', - }, + {**base_model_dict, 'service_object_id': '1234567890', 'name': 'hal9000'}, {**base_model_dict, 'service_object_id': '123', 'name': 'hal9001'}, ] From c9f0aece15235e6d83c67b923f2cc0e8dd4c614d Mon Sep 17 00:00:00 2001 From: Arthur Borem Date: Mon, 15 Sep 2025 14:40:17 -0500 Subject: [PATCH 4/4] fixed groupme object variable name in tests --- tests/test_transfer_services/conftest.py | 2 +- tests/test_transfer_services/test_groupme.py | 71 +++++++++---------- .../test_transfer_services_common.py | 2 +- 3 files changed, 35 insertions(+), 40 deletions(-) diff --git a/tests/test_transfer_services/conftest.py b/tests/test_transfer_services/conftest.py index 41afe7e..de59892 100644 --- a/tests/test_transfer_services/conftest.py +++ b/tests/test_transfer_services/conftest.py @@ -68,7 +68,7 @@ def mock_strava_transfer_service(verticals=[PhysicalActivityVertical]): @pytest.fixture -def mock_groupme_transfer_service( +def groupme_transfer_service( verticals=[BlockedUserVertical, ConversationDirectVertical], ): groupme = GroupMeTransferService( diff --git a/tests/test_transfer_services/test_groupme.py b/tests/test_transfer_services/test_groupme.py index da62c7a..d43706e 100644 --- a/tests/test_transfer_services/test_groupme.py +++ b/tests/test_transfer_services/test_groupme.py @@ -14,73 +14,70 @@ TOKEN = 'fake_token' -def test_fetch_token_raises_no_authorization_response(mock_groupme_transfer_service): +def test_fetch_token_raises_no_authorization_response(groupme_transfer_service): with pytest.raises(ValueError): - mock_groupme_transfer_service.fetch_token(code='code') + groupme_transfer_service.fetch_token(code='code') -def test_fetch_token_raises_no_access_token(mock_groupme_transfer_service): +def test_fetch_token_raises_no_access_token(groupme_transfer_service): with pytest.raises(ValueError): - mock_groupme_transfer_service.fetch_token( + groupme_transfer_service.fetch_token( authorization_response='https://localhostfake?token=badtoken' ) -def test_fetch_token(mock_oauth2_session_request, mock_groupme_transfer_service): +def test_fetch_token(mock_oauth2_session_request, groupme_transfer_service): token = 'faketoken123' - mock_groupme_transfer_service.fetch_token( + groupme_transfer_service.fetch_token( authorization_response=f'https://localhostfake?access_token={token}' ) mock_oauth2_session_request.assert_not_called() assert ( - mock_groupme_transfer_service._oAuth2Session.token.get('access_token', {}) - == token + groupme_transfer_service._oAuth2Session.token.get('access_token', {}) == token ) -def test__fetch_resource_common_raises_exception(mock_groupme_transfer_service, mocker): +def test__fetch_resource_common_raises_exception(groupme_transfer_service, mocker): response_object = mocker.MagicMock() response_object.status_code = 200 response_object.json.return_value = {'response': {'id': USER_ID}} oauth2_session_get = mock_oauth2_session_get(mocker, response_object) - mock_groupme_transfer_service._fetch_resource_common( - 'https://api.groupme.com/v3/fake' - ) + groupme_transfer_service._fetch_resource_common('https://api.groupme.com/v3/fake') assert oauth2_session_get.call_count == 2 - assert mock_groupme_transfer_service._user_id == USER_ID + assert groupme_transfer_service._user_id == USER_ID -def test_fetch_user_data_raises_no_token(mock_groupme_transfer_service): - mock_groupme_transfer_service._oAuth2Session.token = {} +def test_fetch_user_data_raises_no_token(groupme_transfer_service): + groupme_transfer_service._oAuth2Session.token = {} with pytest.raises(UnsupportedRequestException): - mock_groupme_transfer_service.fetch_user_data() + groupme_transfer_service.fetch_user_data() -def test_fetch_user_data(mocker, mock_groupme_transfer_service): +def test_fetch_user_data(mocker, groupme_transfer_service): expected_respose = {'id': USER_ID} response_object = mocker.MagicMock() response_object.json.return_value = {'response': expected_respose} oauth2_session_get = mock_oauth2_session_get(mocker, response_object) - assert mock_groupme_transfer_service.fetch_user_data() == expected_respose - assert mock_groupme_transfer_service._user_id == USER_ID + assert groupme_transfer_service.fetch_user_data() == expected_respose + assert groupme_transfer_service._user_id == USER_ID assert oauth2_session_get.call_args.args[1] == 'https://api.groupme.com/v3/users/me' -def test_fetch_blocked_users_raises_exception(mock_groupme_transfer_service, mocker): - mock_groupme_transfer_service._user_id = USER_ID +def test_fetch_blocked_users_raises_exception(groupme_transfer_service, mocker): + groupme_transfer_service._user_id = USER_ID response_object = mocker.MagicMock() response_object.status_code = 200 response_object.json.return_value = {'response': {'no_blocks': []}} mock_oauth2_session_get(mocker, response_object) with pytest.raises(ValueError): - mock_groupme_transfer_service.fetch_blocked_user_vertical() + groupme_transfer_service.fetch_blocked_user_vertical() -def test_fetch_blocked_user_vertical(mock_groupme_transfer_service, mocker): - mock_groupme_transfer_service._user_id = USER_ID +def test_fetch_blocked_user_vertical(groupme_transfer_service, mocker): + groupme_transfer_service._user_id = USER_ID response_object = mocker.MagicMock() # adapted from # https://dev.groupme.com/docs/v3#blocks_index @@ -97,7 +94,7 @@ def test_fetch_blocked_user_vertical(mock_groupme_transfer_service, mocker): } } oauth2_session_get = mock_oauth2_session_get(mocker, response_object) - model_objs, _ = mock_groupme_transfer_service.fetch_blocked_user_vertical() + model_objs, _ = groupme_transfer_service.fetch_blocked_user_vertical() model_obj_dumps = dump_and_filter_model_objs(model_objs) assert oauth2_session_get.call_args.args[1] == 'https://api.groupme.com/v3/blocks' @@ -126,8 +123,8 @@ def test_fetch_blocked_user_vertical(mock_groupme_transfer_service, mocker): ] -def test_fetch_chat_bot_vertical(mock_groupme_transfer_service, mocker): - mock_groupme_transfer_service._user_id = USER_ID +def test_fetch_chat_bot_vertical(groupme_transfer_service, mocker): + groupme_transfer_service._user_id = USER_ID response_object = mocker.MagicMock() # adapted from # https://dev.groupme.com/docs/v3#bots_index @@ -151,7 +148,7 @@ def test_fetch_chat_bot_vertical(mock_groupme_transfer_service, mocker): ] } oauth2_session_get = mock_oauth2_session_get(mocker, response_object) - model_objs, _ = mock_groupme_transfer_service.fetch_chat_bot_vertical() + model_objs, _ = groupme_transfer_service.fetch_chat_bot_vertical() assert oauth2_session_get.call_args.args[1] == 'https://api.groupme.com/v3/bots' @@ -176,15 +173,13 @@ def test_fetch_chat_bot_vertical(mock_groupme_transfer_service, mocker): 'method_name', ['fetch_conversation_direct_vertical', 'fetch_conversation_group_vertical'], ) -def test_fetch_conversations_raises_exception( - method_name, mock_groupme_transfer_service -): +def test_fetch_conversations_raises_exception(method_name, groupme_transfer_service): with pytest.raises(UnsupportedRequestException): - getattr(mock_groupme_transfer_service, method_name)(count=11) + getattr(groupme_transfer_service, method_name)(count=11) -def test_fetch_conversation_direct_vertical(mock_groupme_transfer_service, mocker): - mock_groupme_transfer_service._user_id = USER_ID +def test_fetch_conversation_direct_vertical(groupme_transfer_service, mocker): + groupme_transfer_service._user_id = USER_ID response_object = mocker.MagicMock() # adapted from # https://dev.groupme.com/docs/v3#chats_index @@ -257,7 +252,7 @@ def test_fetch_conversation_direct_vertical(mock_groupme_transfer_service, mocke ] } oauth2_session_get = mock_oauth2_session_get(mocker, response_object) - model_objs, _ = mock_groupme_transfer_service.fetch_conversation_direct_vertical() + model_objs, _ = groupme_transfer_service.fetch_conversation_direct_vertical() assert oauth2_session_get.call_args.args[1] == 'https://api.groupme.com/v3/chats' @@ -294,8 +289,8 @@ def test_fetch_conversation_direct_vertical(mock_groupme_transfer_service, mocke ] -def test_fetch_conversation_group_vertical(mock_groupme_transfer_service, mocker): - mock_groupme_transfer_service._user_id = USER_ID +def test_fetch_conversation_group_vertical(groupme_transfer_service, mocker): + groupme_transfer_service._user_id = USER_ID response_object = mocker.MagicMock() # adapted from # https://dev.groupme.com/docs/v3#groups_index @@ -417,7 +412,7 @@ def test_fetch_conversation_group_vertical(mock_groupme_transfer_service, mocker ] } oauth2_session_get = mock_oauth2_session_get(mocker, response_object) - model_objs, _ = mock_groupme_transfer_service.fetch_conversation_group_vertical() + model_objs, _ = groupme_transfer_service.fetch_conversation_group_vertical() assert oauth2_session_get.call_args.args[1] == 'https://api.groupme.com/v3/groups' diff --git a/tests/test_transfer_services/test_transfer_services_common.py b/tests/test_transfer_services/test_transfer_services_common.py index 64eb519..2682133 100644 --- a/tests/test_transfer_services/test_transfer_services_common.py +++ b/tests/test_transfer_services/test_transfer_services_common.py @@ -29,7 +29,7 @@ def test_fetch_token( [ 'mock_tumblr_transfer_service', 'mock_strava_transfer_service', - 'mock_groupme_transfer_service', + 'groupme_transfer_service', ], ) def test_fetch_vertical_generic(