microsoft-agents-testing 1.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. microsoft_agents_testing-1.5.0/LICENSE +21 -0
  2. microsoft_agents_testing-1.5.0/MANIFEST.in +1 -0
  3. microsoft_agents_testing-1.5.0/PKG-INFO +159 -0
  4. microsoft_agents_testing-1.5.0/VERSION.txt +1 -0
  5. microsoft_agents_testing-1.5.0/microsoft_agents/testing/__init__.py +8 -0
  6. microsoft_agents_testing-1.5.0/microsoft_agents/testing/_defaults.py +15 -0
  7. microsoft_agents_testing-1.5.0/microsoft_agents/testing/auth/__init__.py +8 -0
  8. microsoft_agents_testing-1.5.0/microsoft_agents/testing/auth/_types.py +57 -0
  9. microsoft_agents_testing-1.5.0/microsoft_agents/testing/auth/mock_user_token_client.py +373 -0
  10. microsoft_agents_testing-1.5.0/microsoft_agents/testing/py.typed +0 -0
  11. microsoft_agents_testing-1.5.0/microsoft_agents/testing/test_adapter.py +550 -0
  12. microsoft_agents_testing-1.5.0/microsoft_agents/testing/test_flow.py +326 -0
  13. microsoft_agents_testing-1.5.0/microsoft_agents/testing/type_def.py +9 -0
  14. microsoft_agents_testing-1.5.0/microsoft_agents_testing.egg-info/PKG-INFO +159 -0
  15. microsoft_agents_testing-1.5.0/microsoft_agents_testing.egg-info/SOURCES.txt +20 -0
  16. microsoft_agents_testing-1.5.0/microsoft_agents_testing.egg-info/dependency_links.txt +1 -0
  17. microsoft_agents_testing-1.5.0/microsoft_agents_testing.egg-info/requires.txt +1 -0
  18. microsoft_agents_testing-1.5.0/microsoft_agents_testing.egg-info/top_level.txt +1 -0
  19. microsoft_agents_testing-1.5.0/pyproject.toml +28 -0
  20. microsoft_agents_testing-1.5.0/readme.md +138 -0
  21. microsoft_agents_testing-1.5.0/setup.cfg +4 -0
  22. microsoft_agents_testing-1.5.0/setup.py +17 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Microsoft Corporation.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE
@@ -0,0 +1 @@
1
+ include VERSION.txt
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: microsoft-agents-testing
3
+ Version: 1.5.0
4
+ Summary: Library for building agent tests using Microsoft Agents SDK
5
+ Author: Microsoft Corporation
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/microsoft/Agents
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: microsoft-agents-hosting-core==1.5.0
19
+ Dynamic: license-file
20
+ Dynamic: requires-dist
21
+
22
+ # Microsoft Agents Testing
23
+
24
+ [![PyPI version](https://img.shields.io/pypi/v/microsoft-agents-testing)](https://pypi.org/project/microsoft-agents-testing/)
25
+
26
+ Testing utilities for the Microsoft 365 Agents SDK for Python. This package
27
+ provides an in-memory channel adapter, fluent conversation assertions, and a
28
+ mock user-token client so agent behavior can be tested without a live channel
29
+ or token service.
30
+
31
+ ## Release Notes
32
+
33
+ <table style="width:100%">
34
+ <tr>
35
+ <th style="width:20%">Version</th>
36
+ <th style="width:20%">Date</th>
37
+ <th style="width:60%">Release Notes</th>
38
+ </tr>
39
+ <tr>
40
+ <td>1.5.0</td>
41
+ <td>2026-08-26</td>
42
+ <td>
43
+ <a href="https://github.com/microsoft/Agents-for-python/blob/main/changelog.md#microsoft-365-agents-sdk-for-python---release-notes-v150">
44
+ 1.5.0 Release Notes
45
+ </a>
46
+ </td>
47
+ </tr>
48
+ </table>
49
+
50
+ ## Features
51
+
52
+ - **`TestAdapter`** runs activities through the normal middleware and agent
53
+ pipeline while capturing outgoing activities in memory.
54
+ - **`TestFlow`** provides fluent helpers for sending activities and asserting
55
+ replies in order.
56
+ - **`MockUserTokenClient`** supports stored tokens, magic codes, sign-out, token
57
+ status, and token-exchange scenarios.
58
+ - **Async-first** APIs work with the SDK's standard `TurnContext` and
59
+ `ChannelAdapter` abstractions.
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pip install microsoft-agents-testing
65
+ ```
66
+
67
+ ## Quick Start
68
+
69
+ ```python
70
+ import pytest
71
+
72
+ from microsoft_agents.hosting.core import TurnContext
73
+ from microsoft_agents.testing import TestAdapter, TestFlow
74
+
75
+
76
+ @pytest.mark.asyncio
77
+ async def test_echo_agent():
78
+ adapter = TestAdapter()
79
+
80
+ async def on_turn(context: TurnContext):
81
+ await context.send_activity(f"Echo: {context.activity.text}")
82
+
83
+ await (
84
+ TestFlow(adapter, on_turn)
85
+ .send("hello")
86
+ .assert_reply("Echo: hello")
87
+ .assert_no_more_replies()
88
+ .start_test()
89
+ )
90
+ ```
91
+
92
+ ## Reply Assertions
93
+
94
+ Replies can be matched by exact text, partial text, activity properties, or a
95
+ custom synchronous or asynchronous validator:
96
+
97
+ ```python
98
+ from microsoft_agents.activity import Activity, ActivityTypes
99
+
100
+
101
+ def validate_reply(reply: Activity):
102
+ assert reply.type == ActivityTypes.message
103
+ assert reply.text is not None
104
+ assert reply.text.startswith("Hello")
105
+
106
+
107
+ await (
108
+ TestFlow(adapter, on_turn)
109
+ .send("hi")
110
+ .assert_reply_contains("Hello")
111
+ .send("details")
112
+ .assert_reply(validate_reply)
113
+ .start_test()
114
+ )
115
+ ```
116
+
117
+ `TestFlow` also supports conversation updates, typing indicators, delays,
118
+ mixed activity transcripts, and configurable reply timeouts.
119
+
120
+ ## Testing OAuth Flows
121
+
122
+ `TestAdapter` uses `MockUserTokenClient` by default. Add a token to test an
123
+ already-authenticated user:
124
+
125
+ ```python
126
+ adapter = TestAdapter(channel_id="msteams")
127
+
128
+ adapter.add_user_token(
129
+ connection_name="my-connection",
130
+ channel_id="msteams",
131
+ user_id="user1",
132
+ token="test-token",
133
+ )
134
+ ```
135
+
136
+ Token exchange can be configured in the same way:
137
+
138
+ ```python
139
+ adapter.add_exchangeable_token(
140
+ connection_name="my-connection",
141
+ channel_id="msteams",
142
+ user_id="user1",
143
+ exchangeable_item="exchange-token",
144
+ token="exchanged-token",
145
+ )
146
+ ```
147
+
148
+ These tokens are stored only in memory and are intended exclusively for tests.
149
+
150
+ ## Scope
151
+
152
+ The adapter models the turn-processing boundary needed by unit tests. It does
153
+ not contact Connector Service, create real proactive conversations, or emulate
154
+ channel-specific delivery behavior.
155
+
156
+ ## Related Packages
157
+
158
+ - [`microsoft-agents-hosting-core`](https://pypi.org/project/microsoft-agents-hosting-core/)
159
+ provides the agent runtime and abstractions used by this package.
@@ -0,0 +1 @@
1
+ 1.5.0
@@ -0,0 +1,8 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ from .auth import MockUserTokenClient
5
+ from .test_adapter import TestAdapter
6
+ from .test_flow import TestFlow
7
+
8
+ __all__ = ["MockUserTokenClient", "TestAdapter", "TestFlow"]
@@ -0,0 +1,15 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ _SERVICE_URL = "https://test.com"
5
+
6
+ _CONV_ID = "convo1"
7
+ _CONV_NAME = "Conversation 1"
8
+
9
+ _BOT_ID = "bot"
10
+ _BOT_NAME = "Bot"
11
+
12
+ _USER_ID = "user1"
13
+ _USER_NAME = "User 1"
14
+
15
+ _LOCALE = "en-US"
@@ -0,0 +1,8 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ from .mock_user_token_client import MockUserTokenClient
5
+
6
+ __all__ = [
7
+ "MockUserTokenClient",
8
+ ]
@@ -0,0 +1,57 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True, eq=False)
9
+ class UserTokenKey:
10
+ """A key that uniquely identifies a user token in the mock client."""
11
+
12
+ connection_name: str
13
+ user_id: str
14
+ channel_id: str
15
+
16
+ def __eq__(self, other: Any) -> bool:
17
+ return (
18
+ isinstance(other, UserTokenKey)
19
+ and self.connection_name.casefold() == other.connection_name.casefold()
20
+ and self.user_id.casefold() == other.user_id.casefold()
21
+ and self.channel_id.casefold() == other.channel_id.casefold()
22
+ )
23
+
24
+ def __hash__(self) -> int:
25
+ return hash(
26
+ (
27
+ self.connection_name.casefold(),
28
+ self.user_id.casefold(),
29
+ self.channel_id.casefold(),
30
+ )
31
+ )
32
+
33
+
34
+ @dataclass(frozen=True, eq=False)
35
+ class ExchangeableTokenKey(UserTokenKey):
36
+ """A key that uniquely identifies an exchangeable token in the mock client."""
37
+
38
+ exchangeable_item: str
39
+
40
+ def __eq__(self, other):
41
+ return (
42
+ super().__eq__(other)
43
+ and isinstance(other, ExchangeableTokenKey)
44
+ and self.exchangeable_item.casefold() == other.exchangeable_item.casefold()
45
+ )
46
+
47
+ def __hash__(self) -> int:
48
+ return hash((super().__hash__(), self.exchangeable_item.casefold()))
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class TokenMagicCode:
53
+ """A class that represents a magic code for a token."""
54
+
55
+ key: UserTokenKey
56
+ magic_code: str
57
+ user_token: str
@@ -0,0 +1,373 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ from uuid import uuid4
5
+
6
+ from microsoft_agents.activity import (
7
+ Activity,
8
+ SignInResource,
9
+ TokenExchangeRequest,
10
+ TokenExchangeResource,
11
+ TokenOrSignInResourceResponse,
12
+ TokenPostResource,
13
+ TokenResponse,
14
+ TokenStatus,
15
+ )
16
+ from microsoft_agents.hosting.core import UserTokenClientBase
17
+
18
+ from ._types import (
19
+ UserTokenKey,
20
+ ExchangeableTokenKey,
21
+ TokenMagicCode,
22
+ )
23
+
24
+ _RAISE_EXCEPTION = "_raise_exception"
25
+
26
+
27
+ class MockUserTokenClient(UserTokenClientBase):
28
+ """In-memory stand-in for ``UserTokenClientBase`` used by ``TestAdapter``.
29
+
30
+ The mock stores user tokens, magic-code tokens, and token-exchange results
31
+ in dictionaries keyed by connection, channel, and user. It lets tests drive
32
+ OAuth prompt and token-exchange flows without calling the Agents token
33
+ service. Sign-in resources are synthetic and deterministic enough for unit
34
+ tests, but they are not valid service URLs and no network call is made.
35
+ """
36
+
37
+ _user_tokens: dict[UserTokenKey, str]
38
+ _exchangable_tokens: dict[ExchangeableTokenKey, str]
39
+ _magic_codes: list[TokenMagicCode]
40
+
41
+ def __init__(self):
42
+ """Create an empty in-memory token store."""
43
+ self._user_tokens = {}
44
+ self._exchangable_tokens = {}
45
+ self._magic_codes = []
46
+
47
+ @property
48
+ def agent_sign_in(self):
49
+ """Agent sign-in operations are not modeled by this mock client."""
50
+ raise NotImplementedError()
51
+
52
+ @property
53
+ def user_token(self):
54
+ """Nested user-token operations are not exposed by this mock client."""
55
+ raise NotImplementedError()
56
+
57
+ def add_user_token(
58
+ self,
59
+ *,
60
+ connection_name: str,
61
+ channel_id: str,
62
+ user_id: str,
63
+ token: str,
64
+ magic_code: str | None = None,
65
+ ) -> None:
66
+ """Add a fake user token that can later be retrieved by OAuth code.
67
+
68
+ Without a magic code, the token is returned immediately for the matching
69
+ connection, channel, and user. With a magic code, the token is held in a
70
+ separate one-time list and is promoted to the normal token store only
71
+ when :meth:`get_user_token` is called with the same code.
72
+
73
+ :param connection_name: The name of the connection.
74
+ :param channel_id: The channel ID.
75
+ :param user_id: The user ID.
76
+ :param token: The token to be added.
77
+ :param magic_code: An optional magic code associated with the token.
78
+ """
79
+ key = UserTokenKey(
80
+ connection_name=connection_name, user_id=user_id, channel_id=channel_id
81
+ )
82
+
83
+ if magic_code is None:
84
+ self._user_tokens[key] = token
85
+ else:
86
+ self._magic_codes.append(
87
+ TokenMagicCode(key=key, magic_code=magic_code, user_token=token)
88
+ )
89
+
90
+ def add_exchangeable_token(
91
+ self,
92
+ *,
93
+ connection_name: str,
94
+ channel_id: str,
95
+ user_id: str,
96
+ exchangeable_item: str,
97
+ token: str,
98
+ ) -> None:
99
+ """Add a fake token-exchange result.
100
+
101
+ ``exchangeable_item`` represents either the exchange request token or
102
+ URI. A later :meth:`exchange_token` call for the same connection,
103
+ channel, user, and item returns ``token``.
104
+ """
105
+
106
+ key = ExchangeableTokenKey(
107
+ connection_name=connection_name,
108
+ user_id=user_id,
109
+ channel_id=channel_id,
110
+ exchangeable_item=exchangeable_item,
111
+ )
112
+
113
+ self._exchangable_tokens[key] = token
114
+
115
+ def raise_on_exchange_request(
116
+ self,
117
+ *,
118
+ connection_name: str,
119
+ channel_id: str,
120
+ user_id: str,
121
+ exchangeable_item: str,
122
+ ) -> None:
123
+ """Make a matching token-exchange request raise an exception.
124
+
125
+ This is a test-only way to simulate token service exchange failures
126
+ without a real service.
127
+
128
+ :param connection_name: The name of the connection.
129
+ :param channel_id: The channel ID.
130
+ :param user_id: The user ID.
131
+ :param exchangeable_item: The item to be exchanged.
132
+ """
133
+ key = ExchangeableTokenKey(
134
+ connection_name=connection_name,
135
+ user_id=user_id,
136
+ channel_id=channel_id,
137
+ exchangeable_item=exchangeable_item,
138
+ )
139
+
140
+ self._exchangable_tokens[key] = _RAISE_EXCEPTION
141
+
142
+ async def get_user_token(
143
+ self,
144
+ user_id: str,
145
+ connection_name: str,
146
+ channel_id: str,
147
+ magic_code: str | None = None,
148
+ ) -> TokenResponse:
149
+ """Retrieve a fake user token from the in-memory store.
150
+
151
+ When ``magic_code`` matches a stored one-time code, the associated token
152
+ is moved into the normal token store before lookup. If no token is found,
153
+ an empty :class:`TokenResponse` is returned.
154
+
155
+ :param user_id: The user ID.
156
+ :param connection_name: The name of the connection.
157
+ :param channel_id: The channel ID.
158
+ :param magic_code: An optional magic code associated with the token.
159
+ :return: A TokenResponse containing the token if found, otherwise an empty TokenResponse.
160
+ """
161
+
162
+ key = UserTokenKey(
163
+ connection_name=connection_name, user_id=user_id, channel_id=channel_id
164
+ )
165
+
166
+ if magic_code is not None:
167
+ index = next(
168
+ (
169
+ i
170
+ for i, mc in enumerate(self._magic_codes)
171
+ if mc.key == key and mc.magic_code == magic_code
172
+ ),
173
+ None,
174
+ )
175
+ if index is not None:
176
+ mc = self._magic_codes.pop(index)
177
+ self.add_user_token(
178
+ connection_name=connection_name,
179
+ channel_id=key.channel_id,
180
+ user_id=key.user_id,
181
+ token=mc.user_token,
182
+ )
183
+
184
+ if key in self._user_tokens:
185
+ token = self._user_tokens[key]
186
+ return TokenResponse(
187
+ token=token,
188
+ connection_name=connection_name,
189
+ )
190
+ return TokenResponse()
191
+
192
+ async def get_sign_in_resource(
193
+ self,
194
+ connection_name: str,
195
+ activity: Activity,
196
+ final_redirect: str | None = None,
197
+ ) -> SignInResource:
198
+ """Return a synthetic sign-in resource for tests.
199
+
200
+ The returned link and token-exchange resource are fake values derived
201
+ from the connection and activity. They are intended only to let tests
202
+ assert that a sign-in prompt would be sent.
203
+
204
+ :param connection_name: The name of the connection.
205
+ :param activity: The activity associated with the sign-in request.
206
+ :param final_redirect: An optional final redirect URL.
207
+ :return: A SignInResource containing the sign-in URL and other details.
208
+ """
209
+ activity_channel_id = activity.channel_id if activity.channel_id else "unknown"
210
+ activity_recipient_id = (
211
+ activity.recipient.id if activity.recipient else "unknown"
212
+ )
213
+ return SignInResource(
214
+ sign_in_link=f"https://fake.com/oauthsignin/{connection_name}/{activity_channel_id}/{activity_recipient_id}",
215
+ token_exchange_resource=TokenExchangeResource(
216
+ id=uuid4().hex, uri=f"api://{connection_name}/resource"
217
+ ),
218
+ token_post_resource=TokenPostResource(
219
+ sas_url=f"https://fake.com/oauthsignin/{connection_name}/token"
220
+ ),
221
+ )
222
+
223
+ async def get_token_or_sign_in_resource(
224
+ self,
225
+ connection_name: str,
226
+ activity: Activity,
227
+ code: str | None = None,
228
+ final_redirect: str | None = None,
229
+ fwd_url: str | None = None,
230
+ ) -> TokenOrSignInResourceResponse:
231
+ """Return either a stored token or a synthetic sign-in resource.
232
+
233
+ This mirrors the token service shortcut used by OAuth prompts: if a
234
+ token is already available for the activity's user/channel, return it;
235
+ otherwise return a fake sign-in resource.
236
+
237
+ :param connection_name: The name of the connection.
238
+ :param activity: The activity associated with the request.
239
+ :param code: An optional magic code associated with the token.
240
+ :param final_redirect: An optional final redirect URL.
241
+ :param fwd_url: An optional forward URL.
242
+ :return: A TokenOrSignInResourceResponse containing either a token or a sign-in resource.
243
+ """
244
+
245
+ if not activity.from_property or not activity.from_property.id:
246
+ raise ValueError("Activity must have a valid 'from' property with an 'id'.")
247
+
248
+ token_response = await self.get_user_token(
249
+ user_id=activity.from_property.id,
250
+ connection_name=connection_name,
251
+ channel_id=activity.channel_id if activity.channel_id else "unknown",
252
+ magic_code=code,
253
+ )
254
+
255
+ if token_response.token:
256
+ return TokenOrSignInResourceResponse(token_response=token_response)
257
+
258
+ return TokenOrSignInResourceResponse(
259
+ sign_in_resource=await self.get_sign_in_resource(
260
+ connection_name=connection_name,
261
+ activity=activity,
262
+ final_redirect=final_redirect,
263
+ )
264
+ )
265
+
266
+ async def sign_out_user(
267
+ self, user_id: str, connection_name: str, channel_id: str
268
+ ) -> None:
269
+ """Sign out a user by removing matching tokens from the mock store."""
270
+ keys_copy = list(self._user_tokens.keys())
271
+ for key in keys_copy:
272
+ if (
273
+ key.channel_id.casefold() == channel_id.casefold()
274
+ and key.user_id.casefold() == user_id.casefold()
275
+ and key.connection_name.casefold() == connection_name.casefold()
276
+ ):
277
+ self._user_tokens.pop(key)
278
+
279
+ async def get_token_status(
280
+ self,
281
+ user_id: str,
282
+ channel_id: str,
283
+ include: str | None = None,
284
+ ) -> list[TokenStatus]:
285
+ """Return token status entries for stored tokens.
286
+
287
+ The mock reports a token as present when one exists in the in-memory
288
+ store for the requested user and channel. ``include`` filters by
289
+ connection name.
290
+
291
+ :param user_id: The user ID.
292
+ :param channel_id: The channel ID.
293
+ :param include: An optional comma-separated list of connection names to filter the results.
294
+ :return: A list of TokenStatus objects representing the token status for the user.
295
+ """
296
+ include_filter = include.split(",") if include else None
297
+ return [
298
+ TokenStatus(
299
+ connection_name=key.connection_name,
300
+ has_token=True,
301
+ service_provider_display_name=key.connection_name,
302
+ )
303
+ for key in self._user_tokens.keys()
304
+ if key.user_id.casefold() == user_id.casefold()
305
+ and key.channel_id.casefold() == channel_id.casefold()
306
+ and (include_filter is None or key.connection_name in include_filter)
307
+ ]
308
+
309
+ async def get_aad_tokens(
310
+ self,
311
+ user_id: str,
312
+ connection_name: str,
313
+ resource_urls: list[str],
314
+ channel_id: str,
315
+ ) -> dict[str, TokenResponse]:
316
+ """Return fake AAD tokens.
317
+
318
+ The Python testing mock currently does not model per-resource AAD token
319
+ acquisition, so this returns an empty mapping.
320
+ """
321
+ return {}
322
+
323
+ async def exchange_token(
324
+ self,
325
+ user_id: str,
326
+ connection_name: str,
327
+ channel_id: str,
328
+ exchange_request: TokenExchangeRequest,
329
+ ) -> TokenResponse:
330
+ """Exchange a fake token or URI for a stored token response.
331
+
332
+ If :meth:`raise_on_exchange_request` configured the matching item to
333
+ fail, this raises an exception. If no matching item is registered, an
334
+ empty :class:`TokenResponse` is returned.
335
+
336
+ :param user_id: The user ID.
337
+ :param connection_name: The name of the connection.
338
+ :param channel_id: The channel ID.
339
+ :param exchange_request: The token exchange request containing the token or URI to be exchanged.
340
+ """
341
+
342
+ exchangeable_value = exchange_request.token or exchange_request.uri
343
+ if not exchangeable_value:
344
+ raise ValueError(
345
+ "Either token or uri must be provided in the exchange request."
346
+ )
347
+
348
+ key = ExchangeableTokenKey(
349
+ connection_name=connection_name,
350
+ user_id=user_id,
351
+ channel_id=channel_id,
352
+ exchangeable_item=exchangeable_value,
353
+ )
354
+
355
+ if key in self._exchangable_tokens:
356
+ token = self._exchangable_tokens[key]
357
+ if token == _RAISE_EXCEPTION:
358
+ raise Exception("Simulated exception during token exchange.")
359
+
360
+ return TokenResponse(
361
+ channel_id=channel_id, connection_name=connection_name, token=token
362
+ )
363
+
364
+ return TokenResponse()
365
+
366
+ async def close(self) -> None:
367
+ """Close the mock client.
368
+
369
+ The mock owns no network connections or other external resources, so
370
+ this is a no-op.
371
+ """
372
+ # In this mock implementation, there's nothing to close.
373
+ pass