dolbyarun
dolbyarun

Reputation: 27

Conversations.SendToConversationAsync crashes on Unit testing

I have the following method in the controller to send a message from the controller itself (Say a welcome message when a user adds the bot)

        private static async Task<string> OnSendOneToOneMessage(Activity activity,
        IList<Attachment> attachments = null)
    {
        var reply = activity.CreateReply();
        if (attachments != null)
        {
            reply.Attachments = attachments;
        }

        if (_connectorClient == null)
        {
            _connectorClient = new ConnectorClient(new Uri(activity.ServiceUrl));
        }

        var resourceResponse = await _connectorClient.Conversations.SendToConversationAsync(reply);
        return resourceResponse.Id;
    }

And the unit test looks like this

[TestClass]
public sealed class MessagesControllerTest
{
    [Test]
    public async Task CheckOnContactRelationUpdate()
    {
        // Few more setup related to dB <deleted>
        var activity = new Mock<Activity>(MockBehavior.Loose);
        activity.Object.Id = activityMessageId;
        activity.Object.Type = ActivityTypes.ContactRelationUpdate;
        activity.Object.Action = ContactRelationUpdateActionTypes.Add;
        activity.Object.From = new ChannelAccount(userId, userName);
        activity.Object.Recipient = new ChannelAccount(AppConstants.BotId, AppConstants.BotName);
        activity.Object.ServiceUrl = serviceUrl;
        activity.Object.ChannelId = channelId;
        activity.Object.Conversation = new ConversationAccount {Id = Guid.NewGuid().ToString()};
        activity.Object.Attachments = Array.Empty<Attachment>();
        activity.Object.Entities = Array.Empty<Entity>();

        var messagesController =
            new MessagesController(mongoDatabase.Object, null)
            {
                Request = new HttpRequestMessage(),
                Configuration = new HttpConfiguration()
            };

        // Act
        var response = await messagesController.Post(activity.Object);
        var responseMessage = await response.Content.ReadAsStringAsync();

        // Assert
        Assert.IsNotEmpty(responseMessage);
    }
}

The method OnSendOneToOneMessage works fine when a user adds the bor. But it crashes for the unit test. Seems i am missing some setup for the POST?

The stack trace is

Result StackTrace:  
   at System.Net.Http.StringContent.GetContentByteArray(String content, Encoding encoding)
   at System.Net.Http.StringContent..ctor(String content, Encoding encoding, String mediaType)
   at System.Net.Http.StringContent..ctor(String content)
   at <>.Controllers.MessagesController.<Post>d__4.MoveNext() in 
   C:\Users....MessagesController.cs:line 75

--- End of stack trace from previous location where exception was thrown --- at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task) at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task) at System.Runtime.CompilerServices.TaskAwaiter`1.GetResult() at BotTest.Controllers.MessagesControllerTest.d__0.MoveNext() in C:\Users....MessagesControllerTest.cs:line 75 --- End of stack trace from previous location where exception was thrown --- at NUnit.Framework.Internal.AsyncInvocationRegion.AsyncTaskInvocationRegion.WaitFor PendingOperationsToComplete(Object invocationResult) at NUnit.Framework.Internal.Commands.TestMethodCommand.RunAsyncTestMethod(TestExecutionContext context) Result Message: System.ArgumentNullException : Value cannot be null. Parameter name: content

And here is the output

Exception thrown: 'System.ArgumentNullException' in mscorlib.dll
Exception thrown:     'Microsoft.Rest.TransientFaultHandling.HttpRequestWithStatusException' in  Microsoft.Rest.ClientRuntime.dll
Exception thrown: 'Microsoft.Rest.TransientFaultHandling.HttpRequestWithStatusException' in mscorlib.dll
Exception thrown: 'Microsoft.Rest.TransientFaultHandling.HttpRequestWithStatusException' in Microsoft.Rest.ClientRuntime.dll
Exception thrown: 'Microsoft.Rest.TransientFaultHandling.HttpRequestWithStatusException' in mscorlib.dll
Exception thrown: 'System.Net.Http.HttpRequestException' in System.Net.Http.dll
Exception thrown: 'System.UnauthorizedAccessException' in   Microsoft.Bot.Connector.dll
Exception thrown: 'System.UnauthorizedAccessException' in mscorlib.dll
Exception thrown: 'System.UnauthorizedAccessException' in System.Net.Http.dll
Exception thrown: 'System.UnauthorizedAccessException' in mscorlib.dll
Exception thrown: 'System.UnauthorizedAccessException' in mscorlib.dll

NOTE: I tried passing the credential in all different ways. Still it crashes on unit testing.

Upvotes: 1

Views: 912

Answers (2)

dolbyarun
dolbyarun

Reputation: 27

Solved this the following way.

First, where is the problem? : The problem is in the endpoint (ApiController) calling SendToConversationAsync fails with authentication error. Whether you mock the connector using "MockConnectorFactory" class available in the BotBuilder or create a new ConnectorClient, if the URI is white listed (In my case it was azurewebsite, so it is white listed), the auth token will not be generated. This is where we hit the auth error on the final call. And passing on the credentials wont help much because the token is generated only for non white listed URI's.

Solution: Derive a TestConnectorClient and implement your own IConversations. Within the your own IConversation implementation set the credential to get a valid bearer token.

The TestConnectorClient looks like this

internal sealed class TestConnectorClient : ConnectorClient
{
    public TestConnectorClient(Uri uri) : base(uri)
    {
        MockedConversations = new TestConversations(this);
    }

    public override IConversations Conversations => MockedConversations;

    public IConversations MockedConversations { private get; set; }
}

The Testconversation implementation below

public sealed class TestConversations : IConversations
{
    public TestConversations(ConnectorClient client)
    {
        Client = client;
    }

    private ConnectorClient Client { get; }

    public Task<HttpOperationResponse<object>> CreateConversationWithHttpMessagesAsync(
        ConversationParameters parameters, Dictionary<string, List<string>> customHeaders = null,
        CancellationToken cancellationToken = new CancellationToken())
    {
        return null;
    }

    public async Task<HttpOperationResponse<object>> SendToConversationWithHttpMessagesAsync(Activity activity,
        string conversationId, Dictionary<string, List<string>> customHeaders = null,
        CancellationToken cancellationToken = default(CancellationToken))
    {
        if (activity == null)
        {
            throw new ValidationException(ValidationRules.CannotBeNull, "activity");
        }
        if (conversationId == null)
        {
            throw new ValidationException(ValidationRules.CannotBeNull, "conversationId");
        }

        // Construct URL
        var baseUrl = Client.BaseUri.AbsoluteUri;
        var url = new Uri(new Uri(baseUrl + (baseUrl.EndsWith("/") ? "" : "/")),
            "v3/conversations/{conversationId}/activities").ToString();
        url = url.Replace("{conversationId}", Uri.EscapeDataString(conversationId));
        // Create HTTP transport objects
        var httpRequest = new HttpRequestMessage
        {
            Method = new HttpMethod("POST"),
            RequestUri = new Uri(url)
        };

        var cred = new MicrosoftAppCredentials("{Your bot id}", "{Your bot pwd}");
        var token = await cred.GetTokenAsync();
        httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
        // Set Headers
        if (customHeaders != null)
        {
            foreach (var header in customHeaders)
            {
                if (httpRequest.Headers.Contains(header.Key))
                {
                    httpRequest.Headers.Remove(header.Key);
                }
                httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);
            }
        }

        // Serialize Request
        var requestContent = SafeJsonConvert.SerializeObject(activity, Client.SerializationSettings);
        httpRequest.Content = new StringContent(requestContent, Encoding.UTF8);
        httpRequest.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json; charset=utf-8");
        // Set Credentials
        if (Client.Credentials != null)
        {
            cancellationToken.ThrowIfCancellationRequested();
            await Client.Credentials.ProcessHttpRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
        }
        // Send Request
        cancellationToken.ThrowIfCancellationRequested();
        var httpResponse = await Client.HttpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
        var statusCode = httpResponse.StatusCode;
        cancellationToken.ThrowIfCancellationRequested();
        string responseContent;
        if ((int) statusCode != 200 && (int) statusCode != 201 && (int) statusCode != 202 &&
            (int) statusCode != 400 && (int) statusCode != 401 && (int) statusCode != 403 &&
            (int) statusCode != 404 && (int) statusCode != 500 && (int) statusCode != 503)
        {
            var ex = new HttpOperationException(
                $"Operation returned an invalid status code '{statusCode}'");
            responseContent = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
            ex.Request = new HttpRequestMessageWrapper(httpRequest, requestContent);
            ex.Response = new HttpResponseMessageWrapper(httpResponse, responseContent);
            httpRequest.Dispose();
            httpResponse.Dispose();
            throw ex;
        }
        // Create Result
        var result = new HttpOperationResponse<object>
        {
            Request = httpRequest,
            Response = httpResponse
        };

        responseContent = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
        try
        {
            result.Body =
                SafeJsonConvert.DeserializeObject<ResourceResponse>(responseContent,
                    Client.DeserializationSettings);
        }
        catch (JsonException ex)
        {
            httpRequest.Dispose();
            httpResponse.Dispose();
            throw new SerializationException("Unable to deserialize the response.", responseContent, ex);
        }
        return result;
    }

    public Task<HttpOperationResponse<object>> UpdateActivityWithHttpMessagesAsync(string conversationId,
        string activityId, Activity activity,
        Dictionary<string, List<string>> customHeaders = null,
        CancellationToken cancellationToken = new CancellationToken())
    {
        return null;
    }

    public Task<HttpOperationResponse<object>> ReplyToActivityWithHttpMessagesAsync(string conversationId,
        string activityId, Activity activity,
        Dictionary<string, List<string>> customHeaders = null,
        CancellationToken cancellationToken = new CancellationToken())
    {
        return null;
    }

    public Task<HttpOperationResponse<ErrorResponse>> DeleteActivityWithHttpMessagesAsync(string conversationId,
        string activityId, Dictionary<string, List<string>> customHeaders = null,
        CancellationToken cancellationToken = new CancellationToken())
    {
        return null;
    }

    public Task<HttpOperationResponse<object>> GetConversationMembersWithHttpMessagesAsync(string conversationId,
        Dictionary<string, List<string>> customHeaders = null,
        CancellationToken cancellationToken = new CancellationToken())
    {
        return null;
    }

    public Task<HttpOperationResponse<object>> GetActivityMembersWithHttpMessagesAsync(string conversationId,
        string activityId, Dictionary<string, List<string>> customHeaders = null,
        CancellationToken cancellationToken = new CancellationToken())
    {
        return null;
    }

    public Task<HttpOperationResponse<object>> UploadAttachmentWithHttpMessagesAsync(string conversationId,
        AttachmentData attachmentUpload,
        Dictionary<string, List<string>> customHeaders = null,
        CancellationToken cancellationToken = new CancellationToken())
    {
        return null;
    }
}

NOTE: If the goal is unit test, method SendToConversationWithHttpMessagesAsync could simply return appropriate expected response. No need to make a real call. In this case for functional testing i am making real call.

And the test case for checking ContactRelationUpdateActionTypes

    [Test]
    [TestCase(ContactRelationUpdateActionTypes.Add, true)]
    [TestCase(ContactRelationUpdateActionTypes.Add, false)]
    [TestCase(ContactRelationUpdateActionTypes.Remove, false)]
    public async Task CheckOnContactRelationUpdate(string actionType, bool isBrandNewUser)
    {
        // Mock dB here

        var activityMessageId = Guid.NewGuid().ToString();
        const string userName = "{Some name}";
        const string userId = "{A real user id for your bot}";
        const string serviceUrl = "https://smba.trafficmanager.net/apis/";
        const string channelId = "skype";
        var activity = new Activity
        {
            Id = activityMessageId,
            Type = ActivityTypes.ContactRelationUpdate,
            Action = ContactRelationUpdateActionTypes.Add,
            From = new ChannelAccount(userId, userName),
            Recipient = new ChannelAccount(AppConstants.BotId, AppConstants.BotName),
            ServiceUrl = serviceUrl,
            ChannelId = channelId,
            Conversation = new ConversationAccount {Id = userId},
            Attachments = Array.Empty<Attachment>(),
            Entities = Array.Empty<Entity>()
        };

        var connectorClient = new TestConnectorClient(new Uri(activity.ServiceUrl));
        connectorClient.MockedConversations = new TestConversations(connectorClient);

        var messagesController = new MessagesController(mongoDatabase.Object, connectorClient)
        {
            Configuration = new HttpConfiguration(),
            Request = new HttpRequestMessage()
        };

        // Act
        var response = await messagesController.Post(activity);
        var responseMessage = await response.Content.ReadAsStringAsync();

        // Assert
        switch (actionType)
        {
            case ContactRelationUpdateActionTypes.Add:
                Assert.IsNotEmpty(responseMessage);
                break;
            case ContactRelationUpdateActionTypes.Remove:
                Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
                break;
        }
    }

In above test, i am testing 3 scenarios.

  1. When Bot gets added by a new user => Expected result is, a welcome message is posted using SendToConversationAsync, which returns a ResourceResponse and my ApiController post back the resource id as the content in HttpResponseMessage.
  2. When Bot gets re added by a user => Expected result is, a welcome back message is posted using SendToConversationAsync, which returns a ResourceResponse and my ApiController post back the resource id as the content in HttpResponseMessage.
  3. When Bot gets removed by a user => Expected result is, In my case, the User model have a field IsFriend which is set/unset depending on whether the bot is added or removed from contacts. Though i could check for a specific string on removal, i simply check for OK response alone.

Upvotes: 0

Ezequiel Jadib
Ezequiel Jadib

Reputation: 14787

Based on your comments, it seems that what you want to do is functional/integration testing.

For that, I would recommend using Direct Line. The only caveat is that the bot would need to be hosted but it's really powerful. The approach consist of using Direct Line to send messages to the hosted bot, capture the response and do asserts based on those Bot test cases.

The best way to see all this implemented is by checking out the AzureBot tests project. There tons of functional tests following this approach.

The beauty is that test are extremely simple, they just define the scenario:

public async Task ShoudListVms()
{
    var testCase = new BotTestCase()
    {
        Action = "list vms",
        ExpectedReply = "Available VMs are",
    };

    await TestRunner.RunTestCase(testCase);
}

All the magic happens in the TestRunner. The BotHelper class has all the interactions with Direct Line, which is configured and initialized in the General class.

I know this is lot to digest, and that you will need to change things here and there, but I think that if you take the time to master this out, it will really help you to do first class functional tests.

Upvotes: 2

Related Questions