Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,7 @@ class _ConversationUpdateBase(CustomBaseModel):
class ConversationUpdateActivity(_ConversationUpdateBase, ActivityBase):
"""Output model for received conversation update activities with required fields and read-only properties."""

channel_data: ConversationChannelData # pyright: ignore [reportGeneralTypeIssues, reportIncompatibleVariableOverride]
"""Channel data with event type information."""
pass


class ConversationUpdateActivityInput(_ConversationUpdateBase, ActivityInputBase):
Expand Down
112 changes: 112 additions & 0 deletions packages/api/tests/unit/test_conversation_update_directline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.

Tests for ConversationUpdateActivity with Direct Line API 3.0 compatibility.
Issue #239: Direct Line sends conversationUpdate without channelData field.
"""

import pytest
from microsoft_teams.api.activities import ActivityTypeAdapter, ConversationUpdateActivity
from microsoft_teams.api.activities.conversation import ConversationUpdateActivityInput
from microsoft_teams.api.models import Account, ConversationAccount


@pytest.mark.unit
class TestConversationUpdateDirectLine:
"""Test ConversationUpdateActivity compatibility with Direct Line API 3.0."""

def test_parse_conversation_update_without_channel_data(self) -> None:
"""Test that ConversationUpdateActivity can be parsed without channelData field.

This simulates the payload sent by Direct Line API 3.0 when starting a conversation.
Direct Line automatically sends conversationUpdate activities without channelData,
which should be accepted by the SDK.
"""
# Payload simulating Direct Line conversationUpdate (no channelData)
payload = {
"type": "conversationUpdate",
"id": "Bh0ETfRaC25",
"timestamp": "2025-12-22T11:29:37.3485747Z",
"serviceUrl": "https://directline.botframework.com/",
"channelId": "directline",
"from": {
"id": "dl_aba7a98ada0ee99e7d54af5df8e00440",
"name": "Bot Tester"
},
"conversation": {
"id": "conv123"
},
"recipient": {
"id": "bot-id",
"name": "Test Bot"
},
"membersAdded": [
{
"id": "user123",
"name": "Test User"
}
]
}

# This should NOT raise a validation error
activity = ActivityTypeAdapter.validate_python(payload)

# Verify it's a ConversationUpdateActivity
assert isinstance(activity, ConversationUpdateActivity)
assert activity.type == "conversationUpdate"
assert activity.channel_id == "directline"
assert activity.channel_data is None
assert activity.members_added is not None
assert len(activity.members_added) == 1
assert activity.members_added[0].id == "user123"

def test_parse_conversation_update_with_channel_data(self) -> None:
"""Test that ConversationUpdateActivity still works with channelData (Teams behavior)."""
payload = {
"type": "conversationUpdate",
"id": "test-id",
"timestamp": "2025-12-22T11:29:37.3485747Z",
"serviceUrl": "https://smba.trafficmanager.net/teams/",
"channelId": "msteams",
"from": {
"id": "bot-id",
"name": "Test Bot"
},
"conversation": {
"id": "conv123"
},
"recipient": {
"id": "user-id",
"name": "Test User"
},
"channelData": {
"eventType": "channelCreated",
"tenant": {
"id": "tenant-id"
}
}
}

activity = ActivityTypeAdapter.validate_python(payload)

assert isinstance(activity, ConversationUpdateActivity)
assert activity.type == "conversationUpdate"
assert activity.channel_id == "msteams"
assert activity.channel_data is not None
assert activity.channel_data.event_type == "channelCreated"

def test_conversation_update_input_without_channel_data(self) -> None:
"""Test creating ConversationUpdateActivityInput without channelData."""
# Create activity input without channel_data
activity = ConversationUpdateActivityInput(
from_=Account(id="user-id", name="User"),
conversation=ConversationAccount(id="conv-id"),
recipient=Account(id="bot-id", name="Bot"),
members_added=[Account(id="new-user", name="New User")]
)

assert activity.type == "conversationUpdate"
assert activity.channel_data is None
assert activity.members_added is not None
assert len(activity.members_added) == 1
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ class ActivityConfig:
method_name="on_soft_delete_message",
input_model=MessageDeleteActivity,
selector=lambda activity: isinstance(activity, MessageDeleteActivity)
and activity.channel_data is not None
and activity.channel_data.event_type == "softDeleteMessage",
output_model=None,
),
Expand All @@ -129,6 +130,7 @@ class ActivityConfig:
method_name="on_undelete_message",
input_model=MessageUpdateActivity,
selector=lambda activity: isinstance(activity, MessageUpdateActivity)
and activity.channel_data is not None
and activity.channel_data.event_type == "undeleteMessage",
output_model=None,
),
Expand All @@ -137,6 +139,7 @@ class ActivityConfig:
method_name="on_edit_message",
input_model=MessageUpdateActivity,
selector=lambda activity: isinstance(activity, MessageUpdateActivity)
and activity.channel_data is not None
and activity.channel_data.event_type == "editMessage",
output_model=None,
),
Expand Down Expand Up @@ -168,6 +171,7 @@ class ActivityConfig:
method_name="on_channel_created",
input_model=ConversationUpdateActivity,
selector=lambda activity: isinstance(activity, ConversationUpdateActivity)
and activity.channel_data is not None
and activity.channel_data.event_type == "channelCreated",
output_model=None,
),
Expand All @@ -176,6 +180,7 @@ class ActivityConfig:
method_name="on_channel_deleted",
input_model=ConversationUpdateActivity,
selector=lambda activity: isinstance(activity, ConversationUpdateActivity)
and activity.channel_data is not None
and activity.channel_data.event_type == "channelDeleted",
output_model=None,
),
Expand All @@ -184,6 +189,7 @@ class ActivityConfig:
method_name="on_channel_renamed",
input_model=ConversationUpdateActivity,
selector=lambda activity: isinstance(activity, ConversationUpdateActivity)
and activity.channel_data is not None
and activity.channel_data.event_type == "channelRenamed",
output_model=None,
),
Expand All @@ -192,6 +198,7 @@ class ActivityConfig:
method_name="on_channel_restored",
input_model=ConversationUpdateActivity,
selector=lambda activity: isinstance(activity, ConversationUpdateActivity)
and activity.channel_data is not None
and activity.channel_data.event_type == "channelRestored",
output_model=None,
),
Expand All @@ -200,6 +207,7 @@ class ActivityConfig:
method_name="on_team_archived",
input_model=ConversationUpdateActivity,
selector=lambda activity: isinstance(activity, ConversationUpdateActivity)
and activity.channel_data is not None
and activity.channel_data.event_type == "teamArchived",
output_model=None,
),
Expand All @@ -208,6 +216,7 @@ class ActivityConfig:
method_name="on_team_deleted",
input_model=ConversationUpdateActivity,
selector=lambda activity: isinstance(activity, ConversationUpdateActivity)
and activity.channel_data is not None
and activity.channel_data.event_type == "teamDeleted",
output_model=None,
),
Expand All @@ -216,6 +225,7 @@ class ActivityConfig:
method_name="on_team_hard_deleted",
input_model=ConversationUpdateActivity,
selector=lambda activity: isinstance(activity, ConversationUpdateActivity)
and activity.channel_data is not None
and activity.channel_data.event_type == "teamHardDeleted",
output_model=None,
),
Expand All @@ -224,6 +234,7 @@ class ActivityConfig:
method_name="on_team_renamed",
input_model=ConversationUpdateActivity,
selector=lambda activity: isinstance(activity, ConversationUpdateActivity)
and activity.channel_data is not None
and activity.channel_data.event_type == "teamRenamed",
output_model=None,
),
Expand All @@ -232,6 +243,7 @@ class ActivityConfig:
method_name="on_team_restored",
input_model=ConversationUpdateActivity,
selector=lambda activity: isinstance(activity, ConversationUpdateActivity)
and activity.channel_data is not None
and activity.channel_data.event_type == "teamRestored",
output_model=None,
),
Expand All @@ -240,6 +252,7 @@ class ActivityConfig:
method_name="on_team_unarchived",
input_model=ConversationUpdateActivity,
selector=lambda activity: isinstance(activity, ConversationUpdateActivity)
and activity.channel_data is not None
and activity.channel_data.event_type == "teamUnarchived",
output_model=None,
),
Expand Down
100 changes: 100 additions & 0 deletions packages/apps/tests/test_conversation_update_routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.

Tests for ConversationUpdateActivity routing with optional channelData.
Issue #239: Ensure selectors handle None channelData gracefully.
"""

import pytest
from microsoft_teams.api.activities import ConversationUpdateActivity
from microsoft_teams.api.models import Account, ConversationAccount
from microsoft_teams.apps.routing.activity_route_configs import ACTIVITY_ROUTES


@pytest.mark.unit
class TestConversationUpdateRouting:
"""Test activity routing for ConversationUpdateActivity with optional channelData."""

@pytest.fixture
def conversation_update_without_channel_data(self) -> ConversationUpdateActivity:
"""Create a ConversationUpdateActivity without channelData (simulates Direct Line)."""
return ConversationUpdateActivity(
type="conversationUpdate",
id="test-id",
channel_id="directline",
service_url="https://directline.botframework.com/",
from_=Account(id="user-id", name="User"),
conversation=ConversationAccount(id="conv-id"),
recipient=Account(id="bot-id", name="Bot"),
members_added=[Account(id="new-user", name="New User")],
channel_data=None
)

@pytest.fixture
def conversation_update_with_channel_data(self) -> ConversationUpdateActivity:
"""Create a ConversationUpdateActivity with channelData (simulates Teams)."""
from microsoft_teams.api.activities.conversation import ConversationChannelData

return ConversationUpdateActivity(
type="conversationUpdate",
id="test-id",
channel_id="msteams",
service_url="https://smba.trafficmanager.net/teams/",
from_=Account(id="user-id", name="User"),
conversation=ConversationAccount(id="conv-id"),
recipient=Account(id="bot-id", name="Bot"),
channel_data=ConversationChannelData(event_type="channelCreated")
)

def test_conversation_update_selector_matches_without_channel_data(
self, conversation_update_without_channel_data: ConversationUpdateActivity
) -> None:
"""Test that conversation_update selector matches activities without channelData."""
config = ACTIVITY_ROUTES["conversation_update"]
assert config.selector(conversation_update_without_channel_data) is True

def test_conversation_update_selector_matches_with_channel_data(
self, conversation_update_with_channel_data: ConversationUpdateActivity
) -> None:
"""Test that conversation_update selector still matches activities with channelData."""
config = ACTIVITY_ROUTES["conversation_update"]
assert config.selector(conversation_update_with_channel_data) is True

def test_channel_created_selector_rejects_without_channel_data(
self, conversation_update_without_channel_data: ConversationUpdateActivity
) -> None:
"""Test that event-specific selectors reject activities without channelData."""
config = ACTIVITY_ROUTES["channel_created"]
# Should not match because channel_data is None
assert config.selector(conversation_update_without_channel_data) is False

def test_channel_created_selector_matches_with_correct_event(
self, conversation_update_with_channel_data: ConversationUpdateActivity
) -> None:
"""Test that event-specific selectors match when channelData has correct event_type."""
config = ACTIVITY_ROUTES["channel_created"]
assert config.selector(conversation_update_with_channel_data) is True

def test_all_conversation_event_selectors_handle_none_channel_data(
self, conversation_update_without_channel_data: ConversationUpdateActivity
) -> None:
"""Test that all conversation event selectors gracefully handle None channelData."""
# These selectors should all return False (not match) without raising errors
event_routes = [
"channel_created",
"channel_deleted",
"channel_renamed",
"channel_restored",
"team_archived",
"team_deleted",
"team_hard_deleted",
"team_renamed",
"team_restored",
"team_unarchived",
]

for route_name in event_routes:
config = ACTIVITY_ROUTES[route_name]
# Should not match, but should not raise an error
assert config.selector(conversation_update_without_channel_data) is False
Loading