diff --git a/packages/api/src/microsoft_teams/api/models/attachment/attachment.py b/packages/api/src/microsoft_teams/api/models/attachment/attachment.py index 04b6213c..8df47ded 100644 --- a/packages/api/src/microsoft_teams/api/models/attachment/attachment.py +++ b/packages/api/src/microsoft_teams/api/models/attachment/attachment.py @@ -11,7 +11,7 @@ class Attachment(CustomBaseModel): """A model representing an attachment.""" - content_type: str + content_type: Optional[str] = None "mimetype/Contenttype for the file" content_url: Optional[str] = None diff --git a/packages/api/src/microsoft_teams/api/models/channel_data/app_info.py b/packages/api/src/microsoft_teams/api/models/channel_data/app_info.py index 3b973fce..10fd4e44 100644 --- a/packages/api/src/microsoft_teams/api/models/channel_data/app_info.py +++ b/packages/api/src/microsoft_teams/api/models/channel_data/app_info.py @@ -13,7 +13,7 @@ class AppInfo(CustomBaseModel): Describes an app """ - id: str + id: Optional[str] = None "Unique identifier representing an app" version: Optional[str] = None diff --git a/packages/api/src/microsoft_teams/api/models/channel_data/channel_info.py b/packages/api/src/microsoft_teams/api/models/channel_data/channel_info.py index b5950306..3e6277ee 100644 --- a/packages/api/src/microsoft_teams/api/models/channel_data/channel_info.py +++ b/packages/api/src/microsoft_teams/api/models/channel_data/channel_info.py @@ -13,7 +13,7 @@ class ChannelInfo(CustomBaseModel): A channel info object which describes the channel. """ - id: str + id: Optional[str] = None "Unique identifier representing a channel" name: Optional[str] = None diff --git a/packages/api/src/microsoft_teams/api/models/channel_data/settings.py b/packages/api/src/microsoft_teams/api/models/channel_data/settings.py index f37f9811..f271fc69 100644 --- a/packages/api/src/microsoft_teams/api/models/channel_data/settings.py +++ b/packages/api/src/microsoft_teams/api/models/channel_data/settings.py @@ -3,6 +3,8 @@ Licensed under the MIT License. """ +from typing import Optional + from ..custom_base_model import CustomBaseModel from .channel_info import ChannelInfo @@ -12,5 +14,5 @@ class ChannelDataSettings(CustomBaseModel): Settings within teams channel data specific to messages received in Microsoft Teams. """ - selected_channel: ChannelInfo + selected_channel: Optional[ChannelInfo] = None "Information about the selected Teams channel." diff --git a/packages/api/src/microsoft_teams/api/models/channel_data/team_info.py b/packages/api/src/microsoft_teams/api/models/channel_data/team_info.py index e873bf37..3aeb82f8 100644 --- a/packages/api/src/microsoft_teams/api/models/channel_data/team_info.py +++ b/packages/api/src/microsoft_teams/api/models/channel_data/team_info.py @@ -14,7 +14,7 @@ class TeamInfo(CustomBaseModel): Describes a team """ - id: str + id: Optional[str] = None "Unique identifier representing a team" name: Optional[str] = None diff --git a/packages/api/src/microsoft_teams/api/models/channel_data/tenant_info.py b/packages/api/src/microsoft_teams/api/models/channel_data/tenant_info.py index 7404c0e2..120856ed 100644 --- a/packages/api/src/microsoft_teams/api/models/channel_data/tenant_info.py +++ b/packages/api/src/microsoft_teams/api/models/channel_data/tenant_info.py @@ -3,6 +3,8 @@ Licensed under the MIT License. """ +from typing import Optional + from ..custom_base_model import CustomBaseModel @@ -11,5 +13,5 @@ class TenantInfo(CustomBaseModel): Describes a tenant """ - id: str + id: Optional[str] = None "Unique identifier representing a tenant" diff --git a/packages/api/src/microsoft_teams/api/models/entity/entity_base.py b/packages/api/src/microsoft_teams/api/models/entity/entity_base.py index 8d4c495e..519174f7 100644 --- a/packages/api/src/microsoft_teams/api/models/entity/entity_base.py +++ b/packages/api/src/microsoft_teams/api/models/entity/entity_base.py @@ -3,11 +3,13 @@ Licensed under the MIT License. """ +from typing import Optional + from ..custom_base_model import CustomBaseModel class EntityBase(CustomBaseModel): """Base entity for entity types.""" - type: str + type: Optional[str] = None "Type identifier for the entity." diff --git a/packages/api/tests/unit/test_empty_inbound_objects.py b/packages/api/tests/unit/test_empty_inbound_objects.py new file mode 100644 index 00000000..7022e139 --- /dev/null +++ b/packages/api/tests/unit/test_empty_inbound_objects.py @@ -0,0 +1,92 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" +# pyright: basic + +from typing import Any, Dict + +import pytest +from microsoft_teams.api.activities import ActivityTypeAdapter + + +def _activity(**overrides: Any) -> Dict[str, Any]: + """A minimal inbound message activity, with room for per-case overrides.""" + activity: Dict[str, Any] = { + "type": "message", + "id": "activity-id", + "text": "hello", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/emea/tenant/", + "from": {"id": "user-id"}, + "conversation": {"id": "conversation-id"}, + "recipient": {"id": "bot-id"}, + } + activity.update(overrides) + return activity + + +@pytest.mark.parametrize( + "overrides", + [ + pytest.param({"channelData": {"app": {}}}, id="channel_data_app"), + pytest.param({"channelData": {"channel": {}}}, id="channel_data_channel"), + pytest.param({"channelData": {"team": {}}}, id="channel_data_team"), + pytest.param({"channelData": {"tenant": {}}}, id="channel_data_tenant"), + pytest.param({"channelData": {"settings": {}}}, id="channel_data_settings"), + pytest.param({"attachments": [{}]}, id="attachment"), + pytest.param({"entities": [{}]}, id="entity"), + ], +) +def test_activity_parses_when_nested_object_is_empty(overrides: Dict[str, Any]) -> None: + """ + The service can send nested objects as empty objects. These are inbound-only models, so a + missing field must not reject the whole activity. + + Regression test for https://github.com/microsoft/teams.py/issues/563, where a + ``channelData.app`` of ``{}`` raised a ValidationError and the activity was dropped. + """ + assert ActivityTypeAdapter.validate_python(_activity(**overrides)) is not None + + +def test_activity_parses_unrecognized_entity_type() -> None: + """An entity type this SDK version predates must not reject the activity.""" + activity = ActivityTypeAdapter.validate_python(_activity(entities=[{"type": "someFutureEntity"}])) + + assert activity.entities is not None + assert activity.entities[0].type == "someFutureEntity" + + +def test_populated_channel_data_is_preserved() -> None: + """Relaxing the required fields must not stop populated values from being parsed.""" + activity = ActivityTypeAdapter.validate_python( + _activity( + channelData={ + "app": {"id": "app-id", "version": "1.2.3"}, + "channel": {"id": "channel-id"}, + "team": {"id": "team-id"}, + "tenant": {"id": "tenant-id"}, + "settings": {"selectedChannel": {"id": "selected-channel-id"}}, + } + ) + ) + + channel_data = activity.channel_data + assert channel_data is not None + assert channel_data.app is not None + assert channel_data.app.id == "app-id" + assert channel_data.app.version == "1.2.3" + assert channel_data.channel is not None + assert channel_data.channel.id == "channel-id" + assert channel_data.team is not None + assert channel_data.team.id == "team-id" + assert channel_data.tenant is not None + assert channel_data.tenant.id == "tenant-id" + assert channel_data.settings is not None + assert channel_data.settings.selected_channel is not None + assert channel_data.settings.selected_channel.id == "selected-channel-id" + + +def test_absent_channel_data_still_parses() -> None: + """The pre-existing behaviour for a wholly absent channelData must be unchanged.""" + assert ActivityTypeAdapter.validate_python(_activity()).channel_data is None