diff --git a/libraries/Microsoft.Bot.Builder.Azure/AssemblyInfo.cs b/libraries/Microsoft.Bot.Builder.Azure/AssemblyInfo.cs new file mode 100644 index 0000000000..8634e2460a --- /dev/null +++ b/libraries/Microsoft.Bot.Builder.Azure/AssemblyInfo.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +// Allows us to access some internal methods from the Microsoft.Bot.Builder.Azure.Tests unit tests so we don't have to use reflection and we get compile checks. +#if SIGNASSEMBLY +[assembly: InternalsVisibleTo("Microsoft.Bot.Builder.Azure.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")] +#else +[assembly: InternalsVisibleTo("Microsoft.Bot.Builder.Azure.Tests")] +#endif diff --git a/libraries/Microsoft.Bot.Builder.Azure/CosmosDbPartitionedStorage.cs b/libraries/Microsoft.Bot.Builder.Azure/CosmosDbPartitionedStorage.cs index f99be86cde..76cbe92d86 100644 --- a/libraries/Microsoft.Bot.Builder.Azure/CosmosDbPartitionedStorage.cs +++ b/libraries/Microsoft.Bot.Builder.Azure/CosmosDbPartitionedStorage.cs @@ -96,6 +96,18 @@ public CosmosDbPartitionedStorage(CosmosDbPartitionedStorageOptions cosmosDbStor _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); } + /// + /// Initializes a new instance of the class. + /// using the provided CosmosDB credentials, database ID, and collection ID. + /// + /// The custom implementation of CosmosClient. + /// Cosmos DB partitioned storage configuration options. + internal CosmosDbPartitionedStorage(CosmosClient client, CosmosDbPartitionedStorageOptions cosmosDbStorageOptions) + : this(cosmosDbStorageOptions) + { + _client = client; + } + /// /// Reads one or more items with matching keys from the Cosmos DB container. /// @@ -445,40 +457,58 @@ private bool IsInDialogState(JToken jToken) => jToken /// /// Internal data structure for storing items in a CosmosDB Collection. /// - private class DocumentStoreItem : IStoreItem + internal class DocumentStoreItem : IStoreItem { /// /// Gets the PartitionKey path to be used for this document type. /// + /// + /// The PartitionKey path to be used for this document type. + /// public static string PartitionKeyPath => "/id"; /// /// Gets or sets the sanitized Id/Key used as PrimaryKey. /// + /// + /// The sanitized Id/Key used as PrimaryKey. + /// [JsonProperty("id")] public string Id { get; set; } /// /// Gets or sets the un-sanitized Id/Key. /// + /// + /// The un-sanitized Id/Key. + /// [JsonProperty("realId")] public string RealId { get; internal set; } /// /// Gets or sets the persisted object. /// + /// + /// The persisted object. + /// [JsonProperty("document")] public JObject Document { get; set; } /// /// Gets or sets the ETag information for handling optimistic concurrency updates. /// + /// + /// The ETag information for handling optimistic concurrency updates. + /// [JsonProperty("_etag")] public string ETag { get; set; } /// /// Gets the PartitionKey value for the document. /// + /// + /// The PartitionKey value for the document. + /// public string PartitionKey => this.Id; } } diff --git a/tests/Microsoft.Bot.Builder.Azure.Tests/CosmosDbPartitionedStorageTests.cs b/tests/Microsoft.Bot.Builder.Azure.Tests/CosmosDbPartitionedStorageTests.cs new file mode 100644 index 0000000000..cc76caa1c7 --- /dev/null +++ b/tests/Microsoft.Bot.Builder.Azure.Tests/CosmosDbPartitionedStorageTests.cs @@ -0,0 +1,380 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using Microsoft.Azure.Cosmos; +using Microsoft.Bot.Builder.Dialogs; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Microsoft.Bot.Builder.Azure.Tests +{ + [Trait("TestCategory", "Storage")] + [Trait("TestCategory", "Storage - CosmosDB Partitioned")] + public class CosmosDbPartitionedStorageTests + { + private CosmosDbPartitionedStorage _storage; + private readonly Mock _container = new Mock(); + + [Fact] + public void ConstructorValidation() + { + // Should work. + _ = new CosmosDbPartitionedStorage( + cosmosDbStorageOptions: new CosmosDbPartitionedStorageOptions + { + CosmosDbEndpoint = "CosmosDbEndpoint", + AuthKey = "AuthKey", + DatabaseId = "DatabaseId", + ContainerId = "ContainerId", + }, + jsonSerializer: JsonSerializer.Create(new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All })); + + // No Options. Should throw. + Assert.Throws(() => new CosmosDbPartitionedStorage(null)); + + // No Endpoint. Should throw. + Assert.Throws(() => new CosmosDbPartitionedStorage(new CosmosDbPartitionedStorageOptions() + { + CosmosDbEndpoint = null, + })); + + // No Auth Key. Should throw. + Assert.Throws(() => new CosmosDbPartitionedStorage(new CosmosDbPartitionedStorageOptions() + { + CosmosDbEndpoint = "CosmosDbEndpoint", + AuthKey = null, + })); + + // No Database Id. Should throw. + Assert.Throws(() => new CosmosDbPartitionedStorage(new CosmosDbPartitionedStorageOptions() + { + CosmosDbEndpoint = "CosmosDbEndpoint", + AuthKey = "AuthKey", + DatabaseId = null, + })); + + // No Container Id. Should throw. + Assert.Throws(() => new CosmosDbPartitionedStorage(new CosmosDbPartitionedStorageOptions() + { + CosmosDbEndpoint = "CosmosDbEndpoint", + AuthKey = "AuthKey", + DatabaseId = "DatabaseId", + ContainerId = null, + })); + + // No JsonSerializer. Should throw. + Assert.Throws(() => new CosmosDbPartitionedStorage( + new CosmosDbPartitionedStorageOptions() + { + CosmosDbEndpoint = "CosmosDbEndpoint", + AuthKey = "AuthKey", + DatabaseId = "DatabaseId", + ContainerId = "ContainerId", + }, null)); + + // KeySuffix with CompatibilityMode == "true". Should throw. + Assert.Throws(() => new CosmosDbPartitionedStorage(new CosmosDbPartitionedStorageOptions() + { + CosmosDbEndpoint = "CosmosDbEndpoint", + AuthKey = "AuthKey", + DatabaseId = "DatabaseId", + ContainerId = "ContainerId", + KeySuffix = "KeySuffix", + CompatibilityMode = true + })); + + // KeySuffix with CompatibilityMode == "false" and invalid characters. Should throw. + Assert.Throws(() => new CosmosDbPartitionedStorage(new CosmosDbPartitionedStorageOptions() + { + CosmosDbEndpoint = "CosmosDbEndpoint", + AuthKey = "AuthKey", + DatabaseId = "DatabaseId", + ContainerId = "ContainerId", + KeySuffix = "?#*test", + CompatibilityMode = false + })); + } + + [Fact] + public async void ReadAsyncValidation() + { + InitStorage(); + + // No keys. Should throw. + await Assert.ThrowsAsync(() => _storage.ReadAsync(null)); + + // Empty keys. Should return empty. + var empty = await _storage.ReadAsync(new string[] { }); + Assert.Empty(empty); + } + + [Fact] + public async void ReadAsync() + { + InitStorage(); + + var resource = new CosmosDbPartitionedStorage.DocumentStoreItem + { + RealId = "RealId", + ETag = "ETag1", + Document = JObject.Parse("{ \"ETag\":\"ETag2\" }") + }; + var itemResponse = new DocumentStoreItemResponseMock(resource); + + _container.Setup(e => e.ReadItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(itemResponse); + + var items = await _storage.ReadAsync(new string[] { "key" }); + + Assert.Single(items); + _container.Verify(e => e.ReadItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async void ReadAsyncPartitionKey() + { + InitStorage("/_partitionKey"); + + var resource = new CosmosDbPartitionedStorage.DocumentStoreItem + { + RealId = "RealId", + ETag = "ETag1", + Document = JObject.Parse("{ \"ETag\":\"ETag2\" }") + }; + var itemResponse = new DocumentStoreItemResponseMock(resource); + + _container.Setup(e => e.ReadItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(itemResponse); + + var items = await _storage.ReadAsync(new string[] { "key" }); + + Assert.Single(items); + _container.Verify(e => e.ReadItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async void ReadAsyncNotFound() + { + InitStorage(); + + _container.Setup(e => e.ReadItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("NotFound", HttpStatusCode.NotFound, 0, "0", 0)); + + var items = await _storage.ReadAsync(new string[] { "key" }); + + Assert.Empty(items); + _container.Verify(e => e.ReadItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async void ReadAsyncFailure() + { + InitStorage(); + + _container.Setup(e => e.ReadItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("InternalServerError", HttpStatusCode.InternalServerError, 0, "0", 0)); + + await Assert.ThrowsAsync(() => _storage.ReadAsync(new string[] { "key" })); + _container.Verify(e => e.ReadItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async void ReadAsyncCustomPartitionKeyFailure() + { + InitStorage("/customKey"); + + await Assert.ThrowsAsync(() => _storage.ReadAsync(new string[] { "key" })); + } + + [Fact] + public async void WriteAsyncValidation() + { + InitStorage(); + + // No changes. Should throw. + await Assert.ThrowsAsync(() => _storage.WriteAsync(null)); + + // Empty changes. Should return. + await _storage.WriteAsync(new Dictionary()); + } + + [Fact] + public async void WriteAsync() + { + InitStorage(); + + _container.Setup(e => e.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())); + + var changes = new Dictionary + { + { "key1", new CosmosDbPartitionedStorage.DocumentStoreItem() }, + { "key2", new CosmosDbPartitionedStorage.DocumentStoreItem { ETag = "*" } }, + { "key3", new CosmosDbPartitionedStorage.DocumentStoreItem { ETag = "ETag" } }, + }; + + await _storage.WriteAsync(changes); + + _container.Verify(e => e.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(3)); + } + + [Fact] + public async void WriteAsyncEmptyTagFailure() + { + InitStorage(); + + var changes = new Dictionary + { + { "key", new CosmosDbPartitionedStorage.DocumentStoreItem { ETag = string.Empty } }, + }; + + await Assert.ThrowsAsync(() => _storage.WriteAsync(changes)); + } + + [Fact] + public async void WriteAsyncFailure() + { + InitStorage(); + + _container.Setup(e => e.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("InternalServerError", HttpStatusCode.InternalServerError, 0, "0", 0)); + + var changes = new Dictionary { { "key", new { } } }; + + await Assert.ThrowsAsync(() => _storage.WriteAsync(changes)); + _container.Verify(e => e.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async void WriteAsyncWithNestedFailure() + { + InitStorage(); + + _container.Setup(e => e.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("InternalServerError", HttpStatusCode.InternalServerError, 0, "0", 0)); + + var nestedJson = GenerateNestedDict(); + + await Assert.ThrowsAsync(() => _storage.WriteAsync(nestedJson)); + _container.Verify(e => e.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async void WriteAsyncWithNestedDialogFailure() + { + InitStorage(); + + _container.Setup(e => e.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("InternalServerError", HttpStatusCode.InternalServerError, 0, "0", 0)); + + var nestedJson = GenerateNestedDict(); + + var dialogInstance = new DialogInstance { State = nestedJson }; + var dialogState = new DialogState(new List { dialogInstance }); + var changes = new Dictionary { { "state", dialogState } }; + + await Assert.ThrowsAsync(() => _storage.WriteAsync(changes)); + _container.Verify(e => e.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async void DeleteAsyncValidation() + { + InitStorage(); + + // No keys. Should throw. + await Assert.ThrowsAsync(() => _storage.DeleteAsync(null)); + } + + [Fact] + public async void DeleteAsync() + { + InitStorage(); + + _container.Setup(e => e.DeleteItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())); + + await _storage.DeleteAsync(new string[] { "key" }); + + _container.Verify(e => e.DeleteItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async void DeleteAsyncNotFound() + { + InitStorage(); + + _container.Setup(e => e.DeleteItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("NotFound", HttpStatusCode.NotFound, 0, "0", 0)); + + await _storage.DeleteAsync(new string[] { "key" }); + + _container.Verify(e => e.DeleteItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async void DeleteAsyncFailure() + { + InitStorage(); + + _container.Setup(e => e.DeleteItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("InternalServerError", HttpStatusCode.InternalServerError, 0, "0", 0)); + + await Assert.ThrowsAsync(() => _storage.DeleteAsync(new string[] { "key" })); + _container.Verify(e => e.DeleteItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + private void InitStorage(string partitionKey = "/id", CosmosDbPartitionedStorageOptions storageOptions = default) + { + var client = new Mock(); + var containerProperties = new ContainerProperties("id", partitionKey); + var containerResponse = new Mock(); + + containerResponse.SetupGet(e => e.Resource) + .Returns(containerProperties); + client.Setup(e => e.GetContainer(It.IsAny(), It.IsAny())) + .Returns(_container.Object); + _container.Setup(e => e.ReadContainerAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(containerResponse.Object); + + var options = storageOptions ?? new CosmosDbPartitionedStorageOptions + { + CosmosDbEndpoint = "CosmosDbEndpoint", + AuthKey = "AuthKey", + DatabaseId = "DatabaseId", + ContainerId = "ContainerId", + }; + _storage = new CosmosDbPartitionedStorage(client.Object, options); + } + + private Dictionary GenerateNestedDict() + { + var nested = new Dictionary(); + var current = new Dictionary(); + + nested.Add("0", current); + for (var i = 1; i <= 127; i++) + { + var child = new Dictionary(); + current.Add(i.ToString(), child); + current = child; + } + + return nested; + } + + private class DocumentStoreItemResponseMock : ItemResponse + { + public DocumentStoreItemResponseMock(CosmosDbPartitionedStorage.DocumentStoreItem resource) + { + Resource = resource; + } + + public override CosmosDbPartitionedStorage.DocumentStoreItem Resource { get; } + } + } +}