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; }
+ }
+ }
+}