From 54dc77eded901f878e76fe696ac288ec49e49cee Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Thu, 18 Jun 2026 14:28:12 -0700 Subject: [PATCH] feat(teams): migrate outbound send/edit/delete/typing to the SDK (#93 PR 2/4) Route the outbound public Adapter methods through the Microsoft Teams SDK ``App``, mirroring upstream adapter-teams@chat@4.30.0, and untangle the Bot Framework / Graph token caches. Outbound delegation: - post_message / start_typing -> self._app.send(conversation_id, activity) - edit_message -> self._app.api.conversations.activities(id).update(msg_id, activity) - delete_message -> self._app.api.conversations.activities(id).delete(msg_id) The SDK splits input from output activity models, so we build MessageActivityInput / TypingActivityInput (the ActivityParams union that app.send / activities.update accept) from the existing camelCase wire dict. The dict is still returned as RawMessage.raw, preserving the public contract (attachment shape, file delivery, returned SentMessage id). Per-thread routing: thread IDs encode a service URL, but the SDK binds the App's ApiClient to one URL at construction. _point_app_api_at retargets the real ApiClient's service-url chain (validated against the SSRF allow-list first) before each call, so different regions / sovereign clouds reach the right endpoint. Errors now pass the raw SDK exception to handleTeamsError, which already understands the SDK exception shape (401 -> AuthenticationError, 429 -> AdapterRateLimitError, etc.). Token-cache untangle: _get_access_token (Bot Framework) and _get_graph_token (Graph) previously shared _access_token / _token_expiry, so the last writer clobbered the other scope's token (a Graph read could ship a Bot Framework token and vice versa). Graph now caches on dedicated _graph_token / _graph_token_expiry fields. _get_access_token + its fields are retained for the still-hand-rolled Bot Framework callers (native streaming via _teams_send in PR 3, and open_dm), now isolated by scope. Streaming is untouched: _teams_send (and its _get_access_token token) remain for the streaming envelope + server-assigned streamId, which app.send does not surface. Removed the now-unused _teams_update / _teams_delete aiohttp helpers. Tests: outbound tests assert the SDK app.send / app.api.conversations.activities are called (not aiohttp); added a token-cache isolation regression proving the two scopes no longer collide, and a per-thread service-url routing regression exercising the real ApiClient chain. --- src/chat_sdk/adapters/teams/adapter.py | 217 +++++++++++++-------- tests/test_teams_adapter.py | 204 +++++++++++++++++--- tests/test_teams_coverage.py | 251 +++++++++++++++++-------- tests/test_teams_extended.py | 32 +++- 4 files changed, 505 insertions(+), 199 deletions(-) diff --git a/src/chat_sdk/adapters/teams/adapter.py b/src/chat_sdk/adapters/teams/adapter.py index d7f6255..08ae1dd 100644 --- a/src/chat_sdk/adapters/teams/adapter.py +++ b/src/chat_sdk/adapters/teams/adapter.py @@ -356,8 +356,21 @@ def __init__(self, config: TeamsAdapterConfig | None = None) -> None: ) self._bot_user_id: str | None = self._app_id or None + # Bot Framework token cache (scope ``api.botframework.com``). Owned by + # ``_get_access_token`` and consumed only by the still-hand-rolled Bot + # Framework paths: native streaming (``_teams_send``, PR 3) and + # ``open_dm``. The SDK ``App`` mints its own Bot Framework token for the + # migrated outbound send/edit/delete/typing paths, so those no longer + # touch this field. self._access_token: str | None = None self._token_expiry: float = 0 + # Microsoft Graph token cache (scope ``graph.microsoft.com``). Kept on + # DEDICATED fields so it can never collide with the Bot Framework token + # above — the two have different scopes, and sharing one cache caused + # last-writer-wins corruption (issue #93). Owned by ``_get_graph_token`` + # and consumed only by the hand-rolled Graph reads. + self._graph_token: str | None = None + self._graph_token_expiry: float = 0 self._token_lock = asyncio.Lock() # Microsoft Teams SDK ``App`` — owns inbound JWT validation and @@ -1327,6 +1340,58 @@ async def _files_to_attachments(self, files: list[FileUpload]) -> list[dict[str, return attachments + def _point_app_api_at(self, service_url: str) -> None: + """Aim the SDK ``App``'s Bot Framework client at ``service_url``. + + The migrated outbound paths call ``self._app.send(...)`` and + ``self._app.api.conversations.activities(...)`` directly (parity with + upstream ``this.app.send`` / ``this.app.api.conversations``). The SDK + binds the App's :class:`ApiClient` to a single service URL at + construction, and ``app.send`` reads ``self.api.service_url`` into the + outgoing :class:`ConversationReference`. Our thread IDs encode a + per-thread service URL, so before each call we retarget the App's API + client — validating against the SSRF allow-list first, exactly as the + retired hand-rolled senders did. + + The setter walks the real :class:`ApiClient`'s service-url chain + (the client itself, its ``conversations`` sub-client, and that + sub-client's ``activities_client``). It is defensive about test doubles + that replace ``self._app.api`` with a mock lacking those attributes — + an ``AttributeError`` there is harmless because the mock ignores the + service URL anyway. + """ + _validate_service_url(service_url) + normalized = service_url.rstrip("/") + api = self._app.api + try: + api.service_url = normalized + conversations = api.conversations + conversations.service_url = normalized + conversations.activities_client.service_url = normalized + except AttributeError: + # ``self._app.api`` is a test double without the real client's + # service-url chain; nothing to retarget. + pass + + @staticmethod + def _message_activity_input(payload: dict[str, Any]) -> Any: + """Build the SDK ``MessageActivityInput`` from our camelCase activity dict. + + The dict we construct (``text`` / ``textFormat`` / ``attachments`` with + ``contentType`` / ``contentUrl`` / ``content`` / ``name`` keys) is the + Bot Framework wire shape, which matches the SDK input model's + serialization aliases — so ``model_validate`` round-trips it directly. + We keep building the dict (it is still returned as ``RawMessage.raw``, + preserving the public contract) and convert at the SDK boundary. + + Upstream constructs a ``MessageActivity``; the Python SDK splits input + (``MessageActivityInput``) from output (``MessageActivity``) models and + ``app.send`` / ``activities.update`` accept only the input variant. + """ + from microsoft_teams.api import MessageActivityInput + + return MessageActivityInput.model_validate(payload) + async def post_message( self, thread_id: str, @@ -1361,8 +1426,16 @@ async def post_message( ) try: - result = await self._teams_send(decoded, activity_payload) - return RawMessage(id=result.get("id", ""), thread_id=thread_id, raw=activity_payload) + self._point_app_api_at(decoded.service_url) + sent = await self._app.send( + decoded.conversation_id, + self._message_activity_input(activity_payload), + ) + return RawMessage( + id=getattr(sent, "id", "") or "", + thread_id=thread_id, + raw=activity_payload, + ) except Exception as error: self._logger.error( "Teams API: send failed", @@ -1371,10 +1444,7 @@ async def post_message( "error": str(error), }, ) - error_dict: dict[str, Any] = {"message": str(error)} - if hasattr(error, "status"): - error_dict["statusCode"] = error.status - _handle_teams_error(error_dict, "postMessage") + _handle_teams_error(error, "postMessage") raise # unreachable: _handle_teams_error always raises # Regular text message @@ -1401,8 +1471,17 @@ async def post_message( ) try: - result = await self._teams_send(decoded, activity_payload) - return RawMessage(id=result.get("id", ""), thread_id=thread_id, raw=activity_payload) + self._point_app_api_at(decoded.service_url) + sent = await self._app.send( + decoded.conversation_id, + self._message_activity_input(activity_payload), + ) + self._logger.debug("Teams API: send response", {"messageId": getattr(sent, "id", None)}) + return RawMessage( + id=getattr(sent, "id", "") or "", + thread_id=thread_id, + raw=activity_payload, + ) except Exception as error: self._logger.error( "Teams API: send failed", @@ -1411,10 +1490,7 @@ async def post_message( "error": str(error), }, ) - error_dict = {"message": str(error)} - if hasattr(error, "status"): - error_dict["statusCode"] = error.status - _handle_teams_error(error_dict, "postMessage") + _handle_teams_error(error, "postMessage") # Should not reach here due to _handle_teams_error always raising raise # pragma: no cover @@ -1467,7 +1543,11 @@ async def edit_message( ) try: - await self._teams_update(decoded, message_id, activity_payload) + self._point_app_api_at(decoded.service_url) + await self._app.api.conversations.activities(decoded.conversation_id).update( + message_id, + self._message_activity_input(activity_payload), + ) except Exception as error: self._logger.error( "Teams API: updateActivity failed", @@ -1477,10 +1557,7 @@ async def edit_message( "error": str(error), }, ) - error_dict = {"message": str(error)} - if hasattr(error, "status"): - error_dict["statusCode"] = error.status - _handle_teams_error(error_dict, "editMessage") + _handle_teams_error(error, "editMessage") raise # unreachable: _handle_teams_error always raises return RawMessage(id=message_id, thread_id=thread_id, raw=activity_payload) @@ -1498,7 +1575,8 @@ async def delete_message(self, thread_id: str, message_id: str) -> None: ) try: - await self._teams_delete(decoded, message_id) + self._point_app_api_at(decoded.service_url) + await self._app.api.conversations.activities(decoded.conversation_id).delete(message_id) except Exception as error: self._logger.error( "Teams API: deleteActivity failed", @@ -1508,10 +1586,7 @@ async def delete_message(self, thread_id: str, message_id: str) -> None: "error": str(error), }, ) - error_dict = {"message": str(error)} - if hasattr(error, "status"): - error_dict["statusCode"] = error.status - _handle_teams_error(error_dict, "deleteMessage") + _handle_teams_error(error, "deleteMessage") raise # unreachable: _handle_teams_error always raises async def add_reaction( @@ -1534,6 +1609,8 @@ async def remove_reaction( async def start_typing(self, thread_id: str, status: str | None = None) -> None: """Send typing indicator to a Teams conversation.""" + from microsoft_teams.api import TypingActivityInput + decoded = self.decode_thread_id(thread_id) self._logger.debug( @@ -1544,7 +1621,8 @@ async def start_typing(self, thread_id: str, status: str | None = None) -> None: ) try: - await self._teams_send(decoded, {"type": "typing"}) + self._point_app_api_at(decoded.service_url) + await self._app.send(decoded.conversation_id, TypingActivityInput()) except Exception as error: self._logger.error( "Teams API: send (typing) failed", @@ -2751,17 +2829,25 @@ def _extract_attachments_from_graph_message(self, msg: dict[str, Any]) -> list[A return attachments async def _get_graph_token(self) -> str: - """Get a Microsoft Graph API access token (OAuth2 client credentials).""" + """Get a Microsoft Graph API access token (OAuth2 client credentials). + + Caches on the DEDICATED ``_graph_token`` / ``_graph_token_expiry`` + fields — never the Bot Framework ``_access_token`` field. The two + tokens carry different scopes (``graph.microsoft.com`` vs + ``api.botframework.com``); sharing one cache slot let whichever was + fetched last clobber the other, so a Graph read could end up sending a + Bot Framework token (and vice versa). See issue #93. + """ import time as _time # Reuse cached token if valid - if self._access_token and _time.time() < self._token_expiry: - return self._access_token + if self._graph_token and _time.time() < self._graph_token_expiry: + return self._graph_token async with self._token_lock: # Double-check after acquiring lock to avoid redundant refreshes - if self._access_token and _time.time() < self._token_expiry: - return self._access_token + if self._graph_token and _time.time() < self._graph_token_expiry: + return self._graph_token tenant_id = self._app_tenant_id or "botframework.com" token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" @@ -2783,16 +2869,25 @@ async def _get_graph_token(self) -> str: f"Failed to get Graph API token: {response.status} {error_text}", ) data = await response.json() - self._access_token = data["access_token"] - self._token_expiry = _time.time() + data.get("expires_in", 3600) - 300 - return self._access_token # type: ignore[return-value] + self._graph_token = data["access_token"] + self._graph_token_expiry = _time.time() + data.get("expires_in", 3600) - 300 + return self._graph_token # type: ignore[return-value] # ========================================================================= # Teams Bot Framework HTTP API helpers # ========================================================================= async def _get_access_token(self) -> str: - """Get a Bot Framework access token (OAuth2 client credentials).""" + """Get a Bot Framework access token (OAuth2 client credentials). + + Scope ``api.botframework.com``, cached on ``_access_token`` / + ``_token_expiry``. The migrated outbound paths + (post/edit/delete/typing) now mint their Bot Framework token through + the SDK ``App``, so this hand-rolled token is consumed only by the + still-hand-rolled Bot Framework callers: native streaming via + :meth:`_teams_send` (PR 3) and :meth:`open_dm`. It must never share a + cache slot with the Graph token (see :meth:`_get_graph_token`). + """ import time if self._access_token and time.time() < self._token_expiry: @@ -2843,7 +2938,14 @@ async def _teams_send( decoded: TeamsThreadId, activity: dict[str, Any], ) -> dict[str, Any]: - """Send an activity to a Teams conversation via Bot Framework REST API.""" + """Send an activity to a Teams conversation via Bot Framework REST API. + + Retained for the still-hand-rolled native streaming path (PR 3), which + needs the raw ``channelData``/``entities`` streaming envelope and the + server-assigned ``streamId`` from the REST response — neither of which + the SDK ``app.send`` surface exposes today. The migrated outbound + public methods no longer use this; they delegate to the SDK. + """ _validate_service_url(decoded.service_url) token = await self._get_access_token() url = f"{decoded.service_url}v3/conversations/{decoded.conversation_id}/activities" @@ -2865,55 +2967,6 @@ async def _teams_send( ) return await response.json() - async def _teams_update( - self, - decoded: TeamsThreadId, - message_id: str, - activity: dict[str, Any], - ) -> None: - """Update an activity in a Teams conversation via Bot Framework REST API.""" - _validate_service_url(decoded.service_url) - token = await self._get_access_token() - url = f"{decoded.service_url}v3/conversations/{decoded.conversation_id}/activities/{message_id}" - - session = await self._get_http_session() - async with session.put( - url, - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - }, - json=activity, - ) as response: - if not response.ok: - error_text = await response.text() - raise NetworkError( - "teams", - f"Teams API error: {response.status} {error_text}", - ) - - async def _teams_delete( - self, - decoded: TeamsThreadId, - message_id: str, - ) -> None: - """Delete an activity from a Teams conversation via Bot Framework REST API.""" - _validate_service_url(decoded.service_url) - token = await self._get_access_token() - url = f"{decoded.service_url}v3/conversations/{decoded.conversation_id}/activities/{message_id}" - - session = await self._get_http_session() - async with session.delete( - url, - headers={"Authorization": f"Bearer {token}"}, - ) as response: - if not response.ok: - error_text = await response.text() - raise NetworkError( - "teams", - f"Teams API error: {response.status} {error_text}", - ) - def create_teams_adapter(config: TeamsAdapterConfig | None = None) -> TeamsAdapter: """Factory function to create a Teams adapter.""" diff --git a/tests/test_teams_adapter.py b/tests/test_teams_adapter.py index b483b3d..a9a3453 100644 --- a/tests/test_teams_adapter.py +++ b/tests/test_teams_adapter.py @@ -37,6 +37,50 @@ def _make_logger(): ) +class _SentActivity: + """Stand-in for the SDK ``SentActivity`` returned by ``app.send`` — only the + ``.id`` attribute matters to the adapter, mirroring upstream's + ``{ id, type }`` mock return value.""" + + def __init__(self, id: str): + self.id = id + + +def _mock_app_send(adapter: TeamsAdapter, sent_id: str = "sent-msg-123") -> AsyncMock: + """Replace ``adapter._app.send`` with an AsyncMock returning a SentActivity. + + Mirrors upstream's ``mockApp.send = vi.fn(async () => ({ id, type }))`` — + the migrated outbound send/typing paths delegate to the SDK ``App.send``. + Returns the mock so tests can assert call count / arguments. + """ + send = AsyncMock(return_value=_SentActivity(sent_id)) + adapter._app.send = send # type: ignore[method-assign] + return send + + +def _mock_app_activities( + adapter: TeamsAdapter, + *, + update_id: str = "edit-msg-1", +) -> tuple[AsyncMock, AsyncMock]: + """Replace ``adapter._app.api`` so ``conversations.activities(id)`` returns a + stub exposing ``update``/``delete`` AsyncMocks. + + Mirrors upstream's editMessage/deleteMessage test mock: + ``mockApp.api = { conversations: { activities: () => ({ update, delete }) } }``. + Returns ``(update_mock, delete_mock)``. + """ + update = AsyncMock(return_value=_SentActivity(update_id)) + delete = AsyncMock(return_value=None) + ops = MagicMock() + ops.update = update + ops.delete = delete + api = MagicMock() + api.conversations.activities = MagicMock(return_value=ops) + adapter._app.api = api # type: ignore[method-assign] + return update, delete + + class _MockAiohttpSession: """Stub for ``aiohttp.ClientSession`` that supports the ``async with session.get(url) as resp`` pattern. @@ -713,7 +757,7 @@ class TestPostMessage: @pytest.mark.asyncio async def test_sends_and_returns_message_id(self): adapter = _make_adapter(app_id="test-app-id", logger=_make_logger()) - adapter._teams_send = AsyncMock(return_value={"id": "sent-msg-123", "type": "message"}) + send = _mock_app_send(adapter, "sent-msg-123") thread_id = adapter.encode_thread_id( TeamsThreadId( @@ -724,14 +768,45 @@ async def test_sends_and_returns_message_id(self): result = await adapter.post_message(thread_id, {"markdown": "Hi there"}) assert result.id == "sent-msg-123" assert result.thread_id == thread_id - adapter._teams_send.assert_called_once() + send.assert_called_once() + # delegates to the SDK App.send with the conversation ID and a + # MessageActivityInput carrying the rendered text + markdown format + conv_id, activity = send.call_args.args + assert conv_id == "19:abc@thread.tacv2" + assert activity.text == "Hi there" + assert activity.text_format == "markdown" + + @pytest.mark.asyncio + async def test_send_failure_maps_to_handle_teams_error(self): + """Mirrors upstream: a 401 from ``app.send`` flows through + ``handleTeamsError`` and surfaces as ``AuthenticationError`` — proving + the raw SDK exception (status on ``.status_code``) reaches the mapper.""" + from chat_sdk.shared.errors import AuthenticationError + + class _SdkError(Exception): + def __init__(self): + super().__init__("Unauthorized") + self.status_code = 401 + + adapter = _make_adapter(app_id="test-app-id", logger=_make_logger()) + send = _mock_app_send(adapter) + send.side_effect = _SdkError() + + thread_id = adapter.encode_thread_id( + TeamsThreadId( + conversation_id="19:abc@thread.tacv2", + service_url="https://smba.trafficmanager.net/teams/", + ) + ) + with pytest.raises(AuthenticationError): + await adapter.post_message(thread_id, {"markdown": "Hi"}) class TestEditMessage: @pytest.mark.asyncio async def test_updates_and_returns(self): adapter = _make_adapter(app_id="test-app-id", logger=_make_logger()) - adapter._teams_update = AsyncMock() + update, _delete = _mock_app_activities(adapter, update_id="edit-msg-1") thread_id = adapter.encode_thread_id( TeamsThreadId( @@ -742,7 +817,64 @@ async def test_updates_and_returns(self): result = await adapter.edit_message(thread_id, "edit-msg-1", {"markdown": "Updated text"}) assert result.id == "edit-msg-1" assert result.thread_id == thread_id - adapter._teams_update.assert_called_once() + # delegates to app.api.conversations.activities(conversationId).update + adapter._app.api.conversations.activities.assert_called_once_with("19:abc@thread.tacv2") + update.assert_called_once() + update_msg_id, update_activity = update.call_args.args + assert update_msg_id == "edit-msg-1" + assert update_activity.text == "Updated text" + assert update_activity.text_format == "markdown" + + +class TestOutboundServiceUrlRouting: + """Each outbound op must retarget the SDK App's Bot Framework client at the + thread's decoded service URL before sending — so different regions / sovereign + clouds reach the right endpoint. Exercises the REAL ``ApiClient`` (not a mock) + to prove ``_point_app_api_at`` actually walks the service-url chain rather than + silently no-opping via its mock-tolerant ``AttributeError`` guard. + """ + + SOVEREIGN_URL = "https://smba.infra.gov.teams.microsoft.us/teams/" + + @pytest.mark.asyncio + async def test_post_message_retargets_real_api_client(self): + adapter = _make_adapter(app_id="test-app-id", logger=_make_logger()) + seen: dict[str, str] = {} + + async def fake_send(conversation_id, activity): + # captured at call time: the real ApiClient is now on the thread's URL + seen["api"] = adapter._app.api.service_url + seen["conversations"] = adapter._app.api.conversations.service_url + seen["activities"] = adapter._app.api.conversations.activities_client.service_url + return _SentActivity("m") + + adapter._app.send = fake_send # type: ignore[method-assign] + tid = adapter.encode_thread_id( + TeamsThreadId(conversation_id="19:abc@thread.tacv2", service_url=self.SOVEREIGN_URL) + ) + await adapter.post_message(tid, {"markdown": "hi"}) + # the trailing slash is normalized off, matching ApiClient's own rstrip + assert seen["api"] == self.SOVEREIGN_URL.rstrip("/") + assert seen["conversations"] == self.SOVEREIGN_URL.rstrip("/") + assert seen["activities"] == self.SOVEREIGN_URL.rstrip("/") + + @pytest.mark.asyncio + async def test_edit_message_retargets_real_activities_client(self): + adapter = _make_adapter(app_id="test-app-id", logger=_make_logger()) + seen: dict[str, str] = {} + + # Patch the real activities_client.update so the routing target is read + # off the REAL client chain (not a wholesale api mock). + async def fake_update(conversation_id, activity_id, activity): + seen["url"] = adapter._app.api.conversations.activities_client.service_url + return _SentActivity(activity_id) + + adapter._app.api.conversations.activities_client.update = fake_update # type: ignore[method-assign] + tid = adapter.encode_thread_id( + TeamsThreadId(conversation_id="19:abc@thread.tacv2", service_url=self.SOVEREIGN_URL) + ) + await adapter.edit_message(tid, "edit-1", {"markdown": "x"}) + assert seen["url"] == self.SOVEREIGN_URL.rstrip("/") class TestFileAttachments: @@ -762,12 +894,21 @@ def _thread_id(adapter: TeamsAdapter) -> str: ) ) + @staticmethod + def _sent_attachments(send: AsyncMock) -> list[dict]: + """Serialize the attachments off the MessageActivityInput handed to the + SDK ``app.send``, back to the camelCase wire dicts — proving the file + attachments actually reached the SDK boundary (not just the raw echo).""" + activity = send.call_args.args[1] + dumped = activity.model_dump(by_alias=True, exclude_none=True) + return dumped.get("attachments", []) + @pytest.mark.asyncio async def test_text_message_with_file(self): from chat_sdk.types import FileUpload, PostableMarkdown adapter = _make_adapter(app_id="test-app-id", logger=_make_logger()) - adapter._teams_send = AsyncMock(return_value={"id": "sent-1", "type": "message"}) + send = _mock_app_send(adapter, "sent-1") message = PostableMarkdown( markdown="here is your report", @@ -775,8 +916,8 @@ async def test_text_message_with_file(self): ) result = await adapter.post_message(self._thread_id(adapter), message) - payload = adapter._teams_send.call_args.args[1] - attachments = payload["attachments"] + # the data-URI attachment reaches the SDK send AND is echoed on raw + attachments = self._sent_attachments(send) assert len(attachments) == 1 att = attachments[0] assert att["contentType"] == "text/csv" @@ -796,7 +937,7 @@ async def test_card_message_with_file(self): from chat_sdk.types import FileUpload, PostableCard adapter = _make_adapter(app_id="test-app-id", logger=_make_logger()) - adapter._teams_send = AsyncMock(return_value={"id": "sent-2", "type": "message"}) + send = _mock_app_send(adapter, "sent-2") message = PostableCard( card=Card(title="Results"), @@ -804,8 +945,7 @@ async def test_card_message_with_file(self): ) await adapter.post_message(self._thread_id(adapter), message) - payload = adapter._teams_send.call_args.args[1] - attachments = payload["attachments"] + attachments = self._sent_attachments(send) # adaptive card attachment AND the file attachment both present assert len(attachments) == 2 assert attachments[0]["contentType"] == "application/vnd.microsoft.card.adaptive" @@ -825,7 +965,7 @@ async def test_edit_message_does_not_carry_files(self): from chat_sdk.types import FileUpload, PostableMarkdown adapter = _make_adapter(app_id="test-app-id", logger=_make_logger()) - adapter._teams_update = AsyncMock() + update, _delete = _mock_app_activities(adapter, update_id="edit-1") message = PostableMarkdown( markdown="updated", @@ -833,7 +973,8 @@ async def test_edit_message_does_not_carry_files(self): ) result = await adapter.edit_message(self._thread_id(adapter), "edit-1", message) - payload = adapter._teams_update.call_args.args[2] + activity = update.call_args.args[1] + payload = activity.model_dump(by_alias=True, exclude_none=True) assert "attachments" not in payload, ( "edit_message must not carry file attachments — outbound file delivery is " f"post_message-only (upstream fidelity); got attachments={payload.get('attachments')!r}" @@ -846,7 +987,7 @@ async def test_file_without_mime_type_defaults_to_octet_stream(self): from chat_sdk.types import FileUpload, PostableMarkdown adapter = _make_adapter(app_id="test-app-id", logger=_make_logger()) - adapter._teams_send = AsyncMock(return_value={"id": "sent-3", "type": "message"}) + send = _mock_app_send(adapter, "sent-3") message = PostableMarkdown( markdown="bin", @@ -854,7 +995,7 @@ async def test_file_without_mime_type_defaults_to_octet_stream(self): ) await adapter.post_message(self._thread_id(adapter), message) - att = adapter._teams_send.call_args.args[1]["attachments"][0] + att = self._sent_attachments(send)[0] assert att["contentType"] == "application/octet-stream" assert att["contentUrl"].startswith("data:application/octet-stream;base64,") @@ -872,16 +1013,16 @@ async def test_file_with_unresolvable_data_is_skipped(self): logger = _make_logger() adapter = _make_adapter(app_id="test-app-id", logger=logger) - adapter._teams_send = AsyncMock(return_value={"id": "sent-4", "type": "message"}) + send = _mock_app_send(adapter, "sent-4") # data is a str, not bytes -> to_buffer returns None -> file skipped bad = FileUpload(data="not-bytes", filename="bad.txt", mime_type="text/plain") # type: ignore[arg-type] message = PostableMarkdown(markdown="text only", files=[bad]) - await adapter.post_message(self._thread_id(adapter), message) + result = await adapter.post_message(self._thread_id(adapter), message) - payload = adapter._teams_send.call_args.args[1] - # no attachments key added when every file was skipped - assert "attachments" not in payload + # no attachments key added when every file was skipped (raw + sent activity) + assert "attachments" not in result.raw + assert self._sent_attachments(send) == [] # assert the SPECIFIC skip log fired — not just that some debug log happened # (post_message emits an unconditional "send (message)" debug, so a bare # logger.debug.called check would pass even if the skip branch logged nothing). @@ -900,7 +1041,7 @@ async def test_multiple_files_attached_in_order(self): from chat_sdk.types import FileUpload, PostableMarkdown adapter = _make_adapter(app_id="test-app-id", logger=_make_logger()) - adapter._teams_send = AsyncMock(return_value={"id": "m", "type": "message"}) + send = _mock_app_send(adapter, "m") message = PostableMarkdown( markdown="three files", @@ -912,7 +1053,7 @@ async def test_multiple_files_attached_in_order(self): ) await adapter.post_message(self._thread_id(adapter), message) - attachments = adapter._teams_send.call_args.args[1]["attachments"] + attachments = self._sent_attachments(send) assert [a["name"] for a in attachments] == ["a.csv", "b.png", "c.pdf"] assert [a["contentType"] for a in attachments] == ["text/csv", "image/png", "application/pdf"] assert all(a["contentUrl"].startswith("data:") for a in attachments) @@ -925,7 +1066,7 @@ async def test_partial_skip_preserves_surviving_files_in_order(self): from chat_sdk.types import FileUpload, PostableMarkdown adapter = _make_adapter(app_id="test-app-id", logger=_make_logger()) - adapter._teams_send = AsyncMock(return_value={"id": "m", "type": "message"}) + send = _mock_app_send(adapter, "m") message = PostableMarkdown( markdown="good bad good", @@ -937,7 +1078,7 @@ async def test_partial_skip_preserves_surviving_files_in_order(self): ) await adapter.post_message(self._thread_id(adapter), message) - attachments = adapter._teams_send.call_args.args[1]["attachments"] + attachments = self._sent_attachments(send) assert [a["name"] for a in attachments] == ["first.csv", "third.csv"] @@ -945,7 +1086,7 @@ class TestDeleteMessage: @pytest.mark.asyncio async def test_deletes_without_error(self): adapter = _make_adapter(app_id="test-app-id", logger=_make_logger()) - adapter._teams_delete = AsyncMock() + _update, delete = _mock_app_activities(adapter) thread_id = adapter.encode_thread_id( TeamsThreadId( @@ -954,7 +1095,9 @@ async def test_deletes_without_error(self): ) ) await adapter.delete_message(thread_id, "del-msg-1") - assert adapter._teams_delete.call_count == 1 + adapter._app.api.conversations.activities.assert_called_once_with("19:abc@thread.tacv2") + assert delete.call_count == 1 + assert delete.call_args.args == ("del-msg-1",) # --------------------------------------------------------------------------- @@ -965,8 +1108,10 @@ async def test_deletes_without_error(self): class TestStartTyping: @pytest.mark.asyncio async def test_sends_typing_activity(self): + from microsoft_teams.api import TypingActivityInput + adapter = _make_adapter(app_id="test-app-id", logger=_make_logger()) - adapter._teams_send = AsyncMock(return_value={"id": "typing-1", "type": "typing"}) + send = _mock_app_send(adapter, "typing-1") thread_id = adapter.encode_thread_id( TeamsThreadId( @@ -975,7 +1120,12 @@ async def test_sends_typing_activity(self): ) ) await adapter.start_typing(thread_id) - assert adapter._teams_send.call_count == 1 + assert send.call_count == 1 + conv_id, activity = send.call_args.args + assert conv_id == "19:abc@thread.tacv2" + # delegates a TypingActivityInput (type == "typing") to the SDK App.send + assert isinstance(activity, TypingActivityInput) + assert activity.type == "typing" # --------------------------------------------------------------------------- diff --git a/tests/test_teams_coverage.py b/tests/test_teams_coverage.py index d71724a..59f6bd7 100644 --- a/tests/test_teams_coverage.py +++ b/tests/test_teams_coverage.py @@ -81,6 +81,50 @@ def _make_logger(): ) +class _SentActivity: + """Stand-in for the SDK ``SentActivity`` returned by ``app.send`` — only the + ``.id`` attribute is read by the adapter.""" + + def __init__(self, id: str): + self.id = id + + +def _mock_app_send(adapter: TeamsAdapter, sent_id: str = "sent-msg-123") -> AsyncMock: + """Replace ``adapter._app.send`` with an AsyncMock returning a SentActivity. + + The migrated outbound send/typing paths delegate to the SDK ``App.send``. + """ + send = AsyncMock(return_value=_SentActivity(sent_id)) + adapter._app.send = send # type: ignore[method-assign] + return send + + +def _mock_app_activities( + adapter: TeamsAdapter, + *, + update_id: str = "edit-msg-1", + update_side_effect: Any = None, + delete_side_effect: Any = None, +) -> tuple[AsyncMock, AsyncMock]: + """Replace ``adapter._app.api`` so ``conversations.activities(id)`` returns a + stub exposing ``update``/``delete`` AsyncMocks (edit/delete delegation). + + Returns ``(update_mock, delete_mock)``. + """ + update = AsyncMock( + return_value=None if update_side_effect else _SentActivity(update_id), + side_effect=update_side_effect, + ) + delete = AsyncMock(return_value=None, side_effect=delete_side_effect) + ops = MagicMock() + ops.update = update + ops.delete = delete + api = MagicMock() + api.conversations.activities = MagicMock(return_value=ops) + adapter._app.api = api # type: ignore[method-assign] + return update, delete + + def _make_mock_state() -> MagicMock: cache: dict[str, Any] = {} state = MagicMock() @@ -254,9 +298,9 @@ async def test_raises_auth_error_on_failure(self): class TestGetGraphToken: async def test_fetches_graph_token(self): adapter = _make_adapter(logger=_make_logger()) - # Reset any cached token - adapter._access_token = None - adapter._token_expiry = 0 + # Reset any cached Graph token (dedicated field — never the BF token) + adapter._graph_token = None + adapter._graph_token_expiry = 0 mock_session = _MockSession( default_response=_mock_aiohttp_response( @@ -273,8 +317,8 @@ async def test_fetches_graph_token(self): async def test_graph_token_error_raises(self): adapter = _make_adapter(logger=_make_logger()) - adapter._access_token = None - adapter._token_expiry = 0 + adapter._graph_token = None + adapter._graph_token_expiry = 0 mock_session = _MockSession(default_response=_mock_aiohttp_response({"error": "failed"}, status=400)) @@ -282,6 +326,92 @@ async def test_graph_token_error_raises(self): await adapter._get_graph_token() +# --------------------------------------------------------------------------- +# Token-cache isolation regression (issue #93) +# --------------------------------------------------------------------------- + + +class TestTokenCacheIsolation: + """The Bot Framework token (``_get_access_token``, scope + ``api.botframework.com``) and the Graph token (``_get_graph_token``, scope + ``graph.microsoft.com``) must use SEPARATE cache fields. + + Before the untangle they shared ``_access_token`` / ``_token_expiry``, so + whichever was fetched last clobbered the other — a Graph read could then + ship a Bot Framework token (and vice versa). These tests lock in the fix. + """ + + @staticmethod + def _scope_routed_session() -> _MockSession: + """An aiohttp session whose token endpoint returns a DIFFERENT token per + requested OAuth scope, so a cross-scope cache hit is observable.""" + bf_resp = _mock_aiohttp_response({"access_token": "bf-token", "expires_in": 3600}) + graph_resp = _mock_aiohttp_response({"access_token": "graph-token", "expires_in": 3600}) + session = _MockSession() + original_post = session.post + + def routed_post(url, **kwargs): + scope = (kwargs.get("data") or {}).get("scope", "") + if "graph.microsoft.com" in scope: + return session._make_cm(graph_resp) + if "botframework.com" in scope: + return session._make_cm(bf_resp) + return original_post(url, **kwargs) + + session.post = routed_post + return session + + async def test_graph_and_bot_framework_tokens_do_not_collide(self): + adapter = _make_adapter(logger=_make_logger()) + session = self._scope_routed_session() + + with patch("aiohttp.ClientSession", return_value=session): + # Fetch Graph first, then Bot Framework, then Graph again. If the two + # shared one cache slot, the second/third call would return the + # clobbered (wrong-scope) token from cache. + graph1 = await adapter._get_graph_token() + bf1 = await adapter._get_access_token() + graph2 = await adapter._get_graph_token() + bf2 = await adapter._get_access_token() + + assert graph1 == "graph-token" + assert bf1 == "bf-token" + # cache hits return the correct per-scope token, not the other scope's + assert graph2 == "graph-token" + assert bf2 == "bf-token" + + async def test_tokens_cached_on_separate_fields(self): + adapter = _make_adapter(logger=_make_logger()) + session = self._scope_routed_session() + + with patch("aiohttp.ClientSession", return_value=session): + await adapter._get_graph_token() + await adapter._get_access_token() + + # each token lives on its OWN field — neither clobbered the other + assert adapter._graph_token == "graph-token" + assert adapter._access_token == "bf-token" + assert adapter._graph_token_expiry > 0 + assert adapter._token_expiry > 0 + + async def test_bot_framework_cache_does_not_satisfy_graph(self): + """A warm Bot Framework cache must NOT short-circuit a Graph fetch — + the regression that motivated the split. Pre-warm the BF token, then + request a Graph token and assert it fetches its own scoped token.""" + adapter = _make_adapter(logger=_make_logger()) + session = self._scope_routed_session() + + with patch("aiohttp.ClientSession", return_value=session): + bf = await adapter._get_access_token() + assert bf == "bf-token" + # BF cache is warm; Graph must still fetch its own scoped token + graph = await adapter._get_graph_token() + + assert graph == "graph-token" + # the warm BF cache was untouched by the Graph fetch + assert adapter._access_token == "bf-token" + + # --------------------------------------------------------------------------- # fetch_messages via Graph API # --------------------------------------------------------------------------- @@ -694,14 +824,14 @@ def data(self): # --------------------------------------------------------------------------- -# postMessage / editMessage / deleteMessage / startTyping via HTTP +# postMessage / editMessage / deleteMessage / startTyping via the SDK # --------------------------------------------------------------------------- -class TestTeamsHTTPOperations: +class TestTeamsSDKOperations: async def test_post_message_with_adaptive_card(self): adapter = _make_adapter(logger=_make_logger()) - adapter._teams_send = AsyncMock(return_value={"id": "card-msg-1"}) + send = _mock_app_send(adapter, "card-msg-1") tid = adapter.encode_thread_id( TeamsThreadId( @@ -719,13 +849,15 @@ async def test_post_message_with_adaptive_card(self): }, ) assert result.id == "card-msg-1" - call_args = adapter._teams_send.call_args[0][1] - assert call_args["type"] == "message" - assert call_args["attachments"][0]["contentType"] == "application/vnd.microsoft.card.adaptive" + # SDK App.send received a MessageActivityInput carrying the adaptive card + activity = send.call_args.args[1] + dumped = activity.model_dump(by_alias=True, exclude_none=True) + assert dumped["type"] == "message" + assert dumped["attachments"][0]["contentType"] == "application/vnd.microsoft.card.adaptive" async def test_post_message_text(self): adapter = _make_adapter(logger=_make_logger()) - adapter._teams_send = AsyncMock(return_value={"id": "text-msg-1"}) + send = _mock_app_send(adapter, "text-msg-1") tid = adapter.encode_thread_id( TeamsThreadId( @@ -735,10 +867,12 @@ async def test_post_message_text(self): ) result = await adapter.post_message(tid, {"markdown": "Hello **world**"}) assert result.id == "text-msg-1" + assert send.call_count == 1 async def test_post_message_send_failure(self): adapter = _make_adapter(logger=_make_logger()) - adapter._teams_send = AsyncMock(side_effect=Exception("connection failed")) + send = _mock_app_send(adapter) + send.side_effect = Exception("connection failed") tid = adapter.encode_thread_id( TeamsThreadId( @@ -751,7 +885,7 @@ async def test_post_message_send_failure(self): async def test_edit_message(self): adapter = _make_adapter(logger=_make_logger()) - adapter._teams_update = AsyncMock() + update, _delete = _mock_app_activities(adapter, update_id="msg-1") tid = adapter.encode_thread_id( TeamsThreadId( @@ -761,11 +895,12 @@ async def test_edit_message(self): ) result = await adapter.edit_message(tid, "msg-1", {"markdown": "Updated"}) assert result.id == "msg-1" - adapter._teams_update.assert_called_once() + update.assert_called_once() + assert update.call_args.args[0] == "msg-1" async def test_edit_message_with_card(self): adapter = _make_adapter(logger=_make_logger()) - adapter._teams_update = AsyncMock() + update, _delete = _mock_app_activities(adapter, update_id="msg-1") tid = adapter.encode_thread_id( TeamsThreadId( @@ -784,10 +919,13 @@ async def test_edit_message_with_card(self): }, ) assert result.id == "msg-1" + activity = update.call_args.args[1] + dumped = activity.model_dump(by_alias=True, exclude_none=True) + assert dumped["attachments"][0]["contentType"] == "application/vnd.microsoft.card.adaptive" async def test_delete_message(self): adapter = _make_adapter(logger=_make_logger()) - adapter._teams_delete = AsyncMock() + _update, delete = _mock_app_activities(adapter) tid = adapter.encode_thread_id( TeamsThreadId( @@ -796,11 +934,12 @@ async def test_delete_message(self): ) ) await adapter.delete_message(tid, "del-1") - assert adapter._teams_delete.call_count == 1 + assert delete.call_count == 1 + assert delete.call_args.args == ("del-1",) async def test_delete_message_failure(self): adapter = _make_adapter(logger=_make_logger()) - adapter._teams_delete = AsyncMock(side_effect=Exception("delete failed")) + _update, _delete = _mock_app_activities(adapter, delete_side_effect=Exception("delete failed")) tid = adapter.encode_thread_id( TeamsThreadId( @@ -813,7 +952,7 @@ async def test_delete_message_failure(self): async def test_start_typing(self): adapter = _make_adapter(logger=_make_logger()) - adapter._teams_send = AsyncMock(return_value={"id": "t1", "type": "typing"}) + send = _mock_app_send(adapter, "t1") tid = adapter.encode_thread_id( TeamsThreadId( @@ -822,14 +961,15 @@ async def test_start_typing(self): ) ) await adapter.start_typing(tid) - adapter._teams_send.assert_called_once() - call_activity = adapter._teams_send.call_args[0][1] - assert call_activity["type"] == "typing" + send.assert_called_once() + activity = send.call_args.args[1] + assert activity.type == "typing" async def test_start_typing_failure_swallowed(self): """Typing failures should be logged but not re-raised.""" adapter = _make_adapter(logger=_make_logger()) - adapter._teams_send = AsyncMock(side_effect=Exception("typing error")) + send = _mock_app_send(adapter) + send.side_effect = Exception("typing error") tid = adapter.encode_thread_id( TeamsThreadId( @@ -843,7 +983,7 @@ async def test_start_typing_failure_swallowed(self): # --------------------------------------------------------------------------- -# _teams_send / _teams_update / _teams_delete HTTP helpers +# _teams_send HTTP helper (retained for the still-hand-rolled streaming path) # --------------------------------------------------------------------------- @@ -1114,7 +1254,6 @@ async def mock_send(decoded, payload): return {"id": f"msg-{send_call_count}"} adapter._teams_send = mock_send - adapter._teams_update = AsyncMock() tid = adapter.encode_thread_id( TeamsThreadId( @@ -1136,7 +1275,6 @@ async def text_stream(): async def test_stream_string_chunks(self): adapter = _make_adapter(logger=_make_logger()) adapter._teams_send = AsyncMock(return_value={"id": "s1"}) - adapter._teams_update = AsyncMock() tid = adapter.encode_thread_id( TeamsThreadId( @@ -1151,14 +1289,12 @@ async def text_stream(): result = await adapter.stream(tid, text_stream()) assert "Hello World" in result.raw["text"] - # Group chat: single accumulate-and-post send, no edits. + # Group chat: single accumulate-and-post send (no per-chunk edits). assert adapter._teams_send.call_count == 1 - assert adapter._teams_update.call_count == 0 async def test_stream_empty_chunks_skipped(self): adapter = _make_adapter(logger=_make_logger()) adapter._teams_send = AsyncMock(return_value={"id": "s1"}) - adapter._teams_update = AsyncMock() tid = adapter.encode_thread_id( TeamsThreadId( @@ -1233,7 +1369,7 @@ def test_extract_card_title_body_not_list(self): # --------------------------------------------------------------------------- -# _teams_send / _teams_update / _teams_delete error paths +# _teams_send error path (retained for the still-hand-rolled streaming path) # --------------------------------------------------------------------------- @@ -1262,54 +1398,6 @@ def routed_post(url, **kwargs): with patch("aiohttp.ClientSession", return_value=mock_session), pytest.raises(NetworkError): await adapter._teams_send(decoded, {"type": "message"}) - async def test_teams_update_non_ok_response(self): - adapter = _make_adapter(logger=_make_logger()) - - token_resp = _mock_aiohttp_response({"access_token": "t", "expires_in": 3600}) - error_resp = _mock_aiohttp_response({"error": "bad"}, status=500) - - mock_session = _MockSession(default_response=error_resp) - original_post = mock_session.post - - def routed_post(url, **kwargs): - if "oauth2" in url: - return mock_session._make_cm(token_resp) - return original_post(url, **kwargs) - - mock_session.post = routed_post - - decoded = TeamsThreadId( - conversation_id="19:abc@thread.tacv2", - service_url="https://smba.trafficmanager.net/teams/", - ) - - with patch("aiohttp.ClientSession", return_value=mock_session), pytest.raises(NetworkError): - await adapter._teams_update(decoded, "msg-1", {"type": "message"}) - - async def test_teams_delete_non_ok_response(self): - adapter = _make_adapter(logger=_make_logger()) - - token_resp = _mock_aiohttp_response({"access_token": "t", "expires_in": 3600}) - error_resp = _mock_aiohttp_response({"error": "bad"}, status=500) - - mock_session = _MockSession(default_response=error_resp) - original_post = mock_session.post - - def routed_post(url, **kwargs): - if "oauth2" in url: - return mock_session._make_cm(token_resp) - return original_post(url, **kwargs) - - mock_session.post = routed_post - - decoded = TeamsThreadId( - conversation_id="19:abc@thread.tacv2", - service_url="https://smba.trafficmanager.net/teams/", - ) - - with patch("aiohttp.ClientSession", return_value=mock_session), pytest.raises(NetworkError): - await adapter._teams_delete(decoded, "msg-1") - # --------------------------------------------------------------------------- # fetch_channel_info with channel context @@ -1944,7 +2032,7 @@ def test_no_body(self): class TestEditMessageError: async def test_edit_message_update_failure(self): adapter = _make_adapter(logger=_make_logger()) - adapter._teams_update = AsyncMock(side_effect=Exception("update failed")) + _mock_app_activities(adapter, update_side_effect=Exception("update failed")) tid = adapter.encode_thread_id( TeamsThreadId( @@ -1964,7 +2052,8 @@ async def test_edit_message_update_failure(self): class TestPostMessageCardError: async def test_post_card_failure(self): adapter = _make_adapter(logger=_make_logger()) - adapter._teams_send = AsyncMock(side_effect=Exception("card send failed")) + send = _mock_app_send(adapter) + send.side_effect = Exception("card send failed") tid = adapter.encode_thread_id( TeamsThreadId( diff --git a/tests/test_teams_extended.py b/tests/test_teams_extended.py index 6dc143b..98dc0bb 100644 --- a/tests/test_teams_extended.py +++ b/tests/test_teams_extended.py @@ -87,6 +87,23 @@ def _make_logger(): ) +class _SentActivity: + """Stand-in for the SDK ``SentActivity`` returned by ``app.send``.""" + + def __init__(self, id: str): + self.id = id + + +def _mock_app_send(adapter: TeamsAdapter, sent_id: str = "sent-msg-123") -> AsyncMock: + """Replace ``adapter._app.send`` with an AsyncMock returning a SentActivity. + + The migrated outbound send/typing paths delegate to the SDK ``App.send``. + """ + send = AsyncMock(return_value=_SentActivity(sent_id)) + adapter._app.send = send # type: ignore[method-assign] + return send + + class _FakeRequest: def __init__(self, body: str, headers: dict[str, str] | None = None): self._body = body @@ -280,7 +297,7 @@ class TestPostMessageAdaptiveCard: @pytest.mark.asyncio async def test_post_card_message(self): adapter = _make_adapter(logger=_make_logger()) - adapter._teams_send = AsyncMock(return_value={"id": "card-msg-1"}) + send = _mock_app_send(adapter, "card-msg-1") tid = adapter.encode_thread_id( TeamsThreadId( @@ -303,10 +320,11 @@ async def test_post_card_message(self): }, ) assert result.id == "card-msg-1" - call_args = adapter._teams_send.call_args[0][1] - assert call_args["type"] == "message" - assert len(call_args["attachments"]) == 1 - assert call_args["attachments"][0]["contentType"] == "application/vnd.microsoft.card.adaptive" + activity = send.call_args.args[1] + dumped = activity.model_dump(by_alias=True, exclude_none=True) + assert dumped["type"] == "message" + assert len(dumped["attachments"]) == 1 + assert dumped["attachments"][0]["contentType"] == "application/vnd.microsoft.card.adaptive" # --------------------------------------------------------------------------- @@ -723,7 +741,6 @@ async def test_group_chat_stream_accumulates_and_posts_single_message(self): """ adapter = _make_adapter(logger=_make_logger()) adapter._teams_send = AsyncMock(return_value={"id": "stream-msg-1"}) - adapter._teams_update = AsyncMock() tid = adapter.encode_thread_id( TeamsThreadId( @@ -740,7 +757,6 @@ async def text_gen(): assert result.id == "stream-msg-1" # Single send carrying the full accumulated text — no edits. assert adapter._teams_send.call_count == 1 - assert adapter._teams_update.call_count == 0 sent_payload = adapter._teams_send.await_args.args[1] assert sent_payload["text"] == "Hello world" assert sent_payload["type"] == "message" @@ -750,7 +766,6 @@ async def test_group_chat_stream_empty_returns_empty(self): """Empty streams in a group chat skip the post entirely.""" adapter = _make_adapter(logger=_make_logger()) adapter._teams_send = AsyncMock(return_value={"id": "stream-msg-2"}) - adapter._teams_update = AsyncMock() tid = adapter.encode_thread_id( TeamsThreadId( @@ -768,4 +783,3 @@ async def text_gen(): assert result.id == "" assert result.raw["text"] == "" assert adapter._teams_send.call_count == 0 - assert adapter._teams_update.call_count == 0