// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Text; using System.Text.Json; using Microsoft.Teams.Apps.Api.Clients; using Microsoft.Teams.Apps.Schema; using Microsoft.Teams.Core; using Microsoft.Teams.Core.Schema; using Xunit.Abstractions; namespace IntegrationTests; /// /// Integration tests for sub-clients making real API calls. /// These tests verify that the ApiClient facade correctly delegates to core ConversationClient /// and that Teams/Meeting-specific BotHttpClient calls work end-to-end. /// public class ApiClientTests : IClassFixture { private readonly IntegrationTestFixture _f; private readonly ITestOutputHelper _output; private readonly ApiClient _api; public ApiClientTests(IntegrationTestFixture fixture, ITestOutputHelper output) { _f = fixture; _f.OutputHelper = output; _output = output; _api = _f.ScopedApiClient; } private static CoreActivity CreateMessageActivity(string text) => CoreActivity.CreateBuilder() .WithType(ActivityType.Message) .WithFrom(IntegrationTestFixture.GetChannelAccountWithAgenticProperties()) .WithProperty("text", text) .Build(); private static CoreActivity CreateMessageActivity(string text, ChannelAccount recipient) => CoreActivity.CreateBuilder() .WithType(ActivityType.Message) .WithFrom(IntegrationTestFixture.GetChannelAccountWithAgenticProperties()) .WithRecipient(recipient) .WithProperty("text", text) .Build(); #region Activities [Fact(Timeout = 5000)] [Trait("Category", "Activities")] public async Task Activities_CreateAsync() { CoreActivity activity = CreateMessageActivity($"[ApiClient.Activities.Create] at `{DateTime.UtcNow:s}`"); SendActivityResponse? res = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, activity); Assert.NotNull(res); Assert.NotNull(res.Id); _output.WriteLine($"Created activity: {res.Id}"); } [Fact(Timeout = 5000)] [Trait("Category", "Activities")] public async Task Activities_UpdateAsync() { CoreActivity original = CreateMessageActivity($"[ApiClient.Activities.Update] Original at `{DateTime.UtcNow:s}`"); SendActivityResponse? sent = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, original); Assert.NotNull(sent?.Id); CoreActivity updated = CreateMessageActivity($"[ApiClient.Activities.Update] Updated at `{DateTime.UtcNow:s}`"); UpdateActivityResponse? res = await _api.Conversations.Activities.UpdateAsync( _f.ConversationId, sent.Id, updated); Assert.NotNull(res?.Id); _output.WriteLine($"Updated activity: {res.Id}"); } [Fact(Timeout = 5000)] [Trait("Category", "Activities")] public async Task Activities_ReplyAsync() { CoreActivity original = CreateMessageActivity($"[ApiClient.Activities.Reply] Parent at `{DateTime.UtcNow:s}`"); SendActivityResponse? sent = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, original); Assert.NotNull(sent?.Id); CoreActivity reply = CreateMessageActivity($"[ApiClient.Activities.Reply] Reply at `{DateTime.UtcNow:s}`"); SendActivityResponse? res = await _api.Conversations.Activities.ReplyAsync( _f.ConversationId, sent.Id, reply); Assert.NotNull(res); _output.WriteLine($"Reply activity: {res?.Id}"); } [Fact(Timeout = 5000)] [Trait("Category", "Activities")] public async Task Activities_DeleteAsync() { CoreActivity activity = CreateMessageActivity($"[ApiClient.Activities.Delete] at `{DateTime.UtcNow:s}`"); SendActivityResponse? sent = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, activity); Assert.NotNull(sent?.Id); await Task.Delay(2000); await _api.Conversations.Activities.DeleteAsync(_f.ConversationId, sent.Id, _f.AgenticIdentity); _output.WriteLine($"Deleted activity: {sent.Id}"); } #endregion #region Targeted Activities [SkippableFact] [Trait("Category", "Activities")] public async Task Activities_CreateTargetedAsync() { Skip.If(_f.AgenticIdentity is not null, "Targeted activities return 500 with agentic identity — service limitation"); CoreActivity activity = CreateMessageActivity( $"[ApiClient.Activities.CreateTargeted] at `{DateTime.UtcNow:s}`", new ChannelAccount { Id = _f.MemberMri1 }); SendActivityResponse? res = await _api.Conversations.Activities.CreateTargetedAsync(_f.ConversationId, activity); Assert.NotNull(res); Assert.NotNull(res.Id); _output.WriteLine($"Created targeted activity: {res.Id}"); } [SkippableFact] [Trait("Category", "Activities")] public async Task Activities_UpdateTargetedAsync() { Skip.If(_f.AgenticIdentity is not null, "Targeted activities return 500 with agentic identity — service limitation"); CoreActivity original = CreateMessageActivity( $"[ApiClient.Activities.UpdateTargeted] Original at `{DateTime.UtcNow:s}`", new ChannelAccount { Id = _f.MemberMri1 }); SendActivityResponse? sent = await _api.Conversations.Activities.CreateTargetedAsync(_f.ConversationId, original); Assert.NotNull(sent?.Id); CoreActivity updated = CreateMessageActivity($"[ApiClient.Activities.UpdateTargeted] Updated at `{DateTime.UtcNow:s}`"); UpdateActivityResponse? res = await _api.Conversations.Activities.UpdateTargetedAsync( _f.ConversationId, sent.Id, updated); Assert.NotNull(res?.Id); _output.WriteLine($"Updated targeted activity: {res.Id}"); } [SkippableFact] [Trait("Category", "Activities")] public async Task Activities_DeleteTargetedAsync() { Skip.If(_f.AgenticIdentity is not null, "Targeted activities return 500 with agentic identity — service limitation"); CoreActivity activity = CreateMessageActivity( $"[ApiClient.Activities.DeleteTargeted] at `{DateTime.UtcNow:s}`", new ChannelAccount { Id = _f.MemberMri1 }); SendActivityResponse? sent = await _api.Conversations.Activities.CreateTargetedAsync(_f.ConversationId, activity); Assert.NotNull(sent?.Id); await Task.Delay(2000); await _api.Conversations.Activities.DeleteTargetedAsync(_f.ConversationId, sent.Id, _f.AgenticIdentity); _output.WriteLine($"Deleted targeted activity: {sent.Id}"); } #endregion #region Members [Fact(Timeout = 5000)] [Trait("Category", "Members")] public async Task Members_GetAsync() { IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); Assert.NotNull(members); Assert.NotEmpty(members); foreach (TeamsChannelAccount? m in members.Take(5)) { _output.WriteLine($"Member: {m?.Id} — {m?.Name}"); } } [SkippableFact(Timeout = 5000)] [Trait("Category", "Members")] public async Task Members_GetPagedAsync() { Skip.If(_f.AgenticIdentity is not null, "Paged members returns 500 with agentic identity — service limitation"); PagedTeamsMembersResult paged = await _api.Conversations.Members.GetPagedAsync(_f.ConversationId, agenticIdentity: _f.AgenticIdentity); Assert.NotNull(paged); Assert.NotEmpty(paged.Members); foreach (TeamsChannelAccount? m in paged.Members.Take(5)) { _output.WriteLine($"Member: {m?.Id} — {m?.Name} {m?.AadObjectId}"); } } [Fact(Timeout = 5000)] [Trait("Category", "Members")] public async Task Members_GetByIdAsync() { string memberId = _f.MemberMri1!; TeamsChannelAccount? member = await _api.Conversations.Members.GetByIdAsync( _f.ConversationId, memberId, _f.AgenticIdentity); Assert.NotNull(member); Assert.Equal(memberId, member.Id); _output.WriteLine($"Member: {member.Id} — {member.Name}"); } [Fact(Timeout = 5000)] [Trait("Category", "Members")] public async Task Members_GetByIdAsync_AsTeamsChannelAccount() { string memberId = _f.MemberMri1!; TeamsChannelAccount member = await _api.Conversations.Members.GetByIdAsync( _f.ConversationId, memberId, _f.AgenticIdentity); Assert.NotNull(member); Assert.Equal(memberId, member.Id); _output.WriteLine($"Member: {member.Id} — {member.Name}, Email: {member.Email}, UPN: {member.UserPrincipalName}"); } #endregion #region Reactions [SkippableFact] [Trait("Category", "Reactions")] public async Task Reactions_AddAndDelete() { Skip.If(_f.AgenticIdentity is not null, "Reactions API returns 404 with agentic identity — service limitation"); Skip.If(_f.IsCanary, "Reactions API returns 404 on canary — service limitation"); CoreActivity activity = CreateMessageActivity($"[ApiClient.Reactions] Test at `{DateTime.UtcNow:s}`"); SendActivityResponse? sent = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, activity); Assert.NotNull(sent?.Id); await _api.Conversations.Reactions.AddAsync(_f.ConversationId, sent.Id, "like", _f.AgenticIdentity); _output.WriteLine("Added 'like' reaction"); await Task.Delay(1000); await _api.Conversations.Reactions.DeleteAsync(_f.ConversationId, sent.Id, "like", _f.AgenticIdentity); _output.WriteLine("Removed 'like' reaction"); } #endregion #region Teams [Fact(Timeout = 5000)] [Trait("Category", "Teams")] public async Task Teams_GetByIdAsync() { Team? team = await _api.Teams.GetByIdAsync(_f.TeamId, _f.AgenticIdentity); Assert.NotNull(team); _output.WriteLine($"Team: {team.Id} — {team.Name}, Members: {team.MemberCount}, Channels: {team.ChannelCount}"); } [Fact(Timeout = 5000)] [Trait("Category", "Teams")] public async Task Teams_GetConversationsAsync() { List? channels = await _api.Teams.GetConversationsAsync(_f.TeamId, _f.AgenticIdentity); Assert.NotNull(channels); Assert.NotEmpty(channels); foreach (TeamsChannel ch in channels) { _output.WriteLine($"Channel: {ch.Id} — {ch.Name}"); } } #endregion #region Meetings [Fact(Timeout = 5000)] [Trait("Category", "Meetings")] public async Task Meetings_GetByIdAsync() { Meeting? meeting = await _api.Meetings.GetByIdAsync(_f.MeetingId, _f.AgenticIdentity); Assert.NotNull(meeting); _output.WriteLine($"Meeting: {meeting.Id}"); if (meeting.Details is not null) { _output.WriteLine($" Title: {meeting.Details.Title}, Type: {meeting.Details.Type}"); } } [SkippableFact(Timeout = 15000)] [Trait("Category", "Meetings")] public async Task Meetings_GetParticipantAsync() { // The meetings participant API requires AAD object ID, not MRI/pairwise bot framework ID. // Use cached members to find one with an AAD object ID. string? aadObjectId = null; foreach (TeamsChannelAccount? m in _f.CachedMembers!) { if (m?.Id is null) continue; if (m.AadObjectId is not null) { aadObjectId = m.AadObjectId; break; } // If not available on the cached list, fetch full details for this member TeamsChannelAccount tm = await _api.Conversations.Members .GetByIdAsync(_f.ConversationId, m.Id, _f.AgenticIdentity); if (tm.AadObjectId is not null) { aadObjectId = tm.AadObjectId; break; } } Skip.If(aadObjectId is null, "No members with AAD object ID found in test conversation"); MeetingParticipant? participant = await _api.Meetings.GetParticipantAsync( _f.MeetingId, aadObjectId!, _f.TenantId, _f.AgenticIdentity); Assert.NotNull(participant); _output.WriteLine($"Participant: {participant.User?.Id} — Role: {participant.Meeting?.Role}, InMeeting: {participant.Meeting?.InMeeting}"); } #endregion #region Users — SignIn [SkippableFact(Timeout = 5000)] [Trait("Category", "Users")] public async Task Users_GetSignInUrlAsync() { Skip.If(_f.AgenticIdentity is not null, "UserTokenClient does not support agentic identity"); string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") ?? throw new InvalidOperationException("TEST_CONNECTION_NAME not set"); var tokenExchangeState = new { ConnectionName = connectionName, Conversation = new { User = new ChannelAccount { Id = _f.UserId }, } }; string tokenExchangeStateJson = JsonSerializer.Serialize(tokenExchangeState); string state = Convert.ToBase64String(Encoding.UTF8.GetBytes(tokenExchangeStateJson)); string? url = await _api.UserToken.GetSignInUrlAsync(state); Assert.NotNull(url); Assert.StartsWith("https://", url); _output.WriteLine($"SignIn URL: {url}"); } [SkippableFact(Timeout = 5000)] [Trait("Category", "Users")] public async Task Users_GetSignInResourceAsync() { Skip.If(_f.AgenticIdentity is not null, "UserTokenClient does not support agentic identity"); string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") ?? throw new InvalidOperationException("TEST_CONNECTION_NAME not set"); var tokenExchangeState = new { ConnectionName = connectionName, Conversation = new { User = new ChannelAccount { Id = _f.UserId }, } }; string tokenExchangeStateJson = JsonSerializer.Serialize(tokenExchangeState); string state = Convert.ToBase64String(Encoding.UTF8.GetBytes(tokenExchangeStateJson)); GetSignInResourceResult? resource = await _api.UserToken.GetSignInResourceAsync(state); Assert.NotNull(resource); _output.WriteLine($"SignIn Resource: {resource.SignInLink}"); } #endregion #region Users — Token [SkippableFact(Timeout = 5000)] [Trait("Category", "Users")] public async Task Users_Token_GetStatusAsync() { Skip.If(_f.AgenticIdentity is not null, "UserTokenClient does not support agentic identity"); string userId = _f.MemberMri1!; IList? statuses = await _api.UserToken.GetStatusAsync(userId, "msteams"); // May return null or empty if user has no token connections — that's OK _output.WriteLine($"Token statuses: {statuses?.Count ?? 0} connections"); if (statuses is not null) { foreach (GetTokenStatusResult s in statuses) { _output.WriteLine($" Connection: {s.ConnectionName}, HasToken: {s.HasToken}"); } } } [SkippableFact(Timeout = 5000)] [Trait("Category", "Users")] public async Task Users_Token_GetAsync() { Skip.If(_f.AgenticIdentity is not null, "UserTokenClient does not support agentic identity"); string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") ?? throw new InvalidOperationException("TEST_CONNECTION_NAME not set"); GetTokenResult? result = await _api.UserToken.GetAsync(_f.MemberMri1!, connectionName, "msteams"); _output.WriteLine($"Token: {(result is not null ? "acquired" : "not available")}"); } [SkippableFact(Timeout = 5000)] [Trait("Category", "Users")] public async Task Users_Token_SignOutAsync() { Skip.If(_f.AgenticIdentity is not null, "UserTokenClient does not support agentic identity"); string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") ?? throw new InvalidOperationException("TEST_CONNECTION_NAME not set"); await _api.UserToken.SignOutAsync(_f.MemberMri1!, connectionName, "msteams"); _output.WriteLine("SignOut completed"); } #endregion #region ForServiceUrl [Fact(Timeout = 5000)] [Trait("Category", "Client")] public async Task ForServiceUrl_CreatesScopedClient() { ApiClient scoped = _f.ApiClient.ForServiceUrl(_f.ServiceUrl); Assert.NotNull(scoped.Conversations); Assert.NotNull(scoped.Teams); Assert.NotNull(scoped.Meetings); Assert.Equal(_f.ServiceUrl, scoped.ServiceUrl); // Verify the scoped client can make a real call IList members = await scoped.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); Assert.NotNull(members); Assert.NotEmpty(members); _output.WriteLine($"ForServiceUrl scoped client retrieved {members.Count} members"); } #endregion }