diff --git a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionCommitter.cs b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionCommitter.cs index 838697efe0..4e78d03679 100644 --- a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionCommitter.cs +++ b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionCommitter.cs @@ -95,8 +95,8 @@ private static void EnrichRequestMessage(RequestMessage requestMessage, Distribu { // Set DTC-specific headers requestMessage.Headers.Add(HttpConstants.HttpHeaders.IdempotencyToken, serverRequest.IdempotencyToken.ToString()); - requestMessage.Headers.Add(HttpConstants.HttpHeaders.OperationType, requestMessage.OperationType.ToString()); - requestMessage.Headers.Add(HttpConstants.HttpHeaders.ResourceType, requestMessage.ResourceType.ToString()); + requestMessage.Headers.Add(HttpConstants.HttpHeaders.OperationType, requestMessage.OperationType.ToOperationTypeString()); + requestMessage.Headers.Add(HttpConstants.HttpHeaders.ResourceType, requestMessage.ResourceType.ToResourceTypeString()); requestMessage.UseGatewayMode = true; } diff --git a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs index 3618c55a86..de63261e1e 100644 --- a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs +++ b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs @@ -42,10 +42,15 @@ internal DistributedTransactionOperationResult(DistributedTransactionOperationRe /// /// Initializes a new instance of the class. - /// This protected constructor is intended for use by derived classes. /// + /// + /// Must be public for System.Text.Json reflection-based deserialization. + /// System.Text.Json 6.x only scans BindingFlags.Public constructors when resolving + /// ; non-public constructors are not found. + /// Support for non-public constructors was added in System.Text.Json 7.0. + /// [JsonConstructor] - protected DistributedTransactionOperationResult() + public DistributedTransactionOperationResult() { } @@ -60,7 +65,7 @@ protected DistributedTransactionOperationResult() /// Gets the HTTP status code returned by the operation. /// [JsonInclude] - [JsonPropertyName("statuscode")] + [JsonPropertyName("statusCode")] public virtual HttpStatusCode StatusCode { get; internal set; } /// @@ -91,33 +96,27 @@ protected DistributedTransactionOperationResult() [JsonIgnore] public virtual Stream ResourceStream { get; internal set; } - /// - /// Used for JSON deserialization of the base64-encoded resource body. - /// - [JsonInclude] - [JsonPropertyName("resourcebody")] - internal string ResourceBodyBase64 - { - get => null; // Write-only for deserialization - set - { - if (!string.IsNullOrEmpty(value)) - { - byte[] resourceBody = Convert.FromBase64String(value); - this.ResourceStream = new MemoryStream(resourceBody, 0, resourceBody.Length, writable: false, publiclyVisible: true); - } - } - } - /// /// Request charge in request units for the operation. /// + [JsonInclude] [JsonPropertyName("requestCharge")] - internal virtual double RequestCharge { get; set; } + public virtual double RequestCharge { get; internal set; } - [JsonPropertyName("substatuscode")] + [JsonIgnore] internal virtual SubStatusCodes SubStatusCode { get; set; } + /// + /// Gets the sub-status code value as an unsigned integer. + /// + [JsonInclude] + [JsonPropertyName("subStatusCode")] + public virtual uint SubStatusCodeValue + { + get => (uint)this.SubStatusCode; + internal set => this.SubStatusCode = (SubStatusCodes)value; + } + /// /// ActivityId related to the operation. /// @@ -127,6 +126,11 @@ internal string ResourceBodyBase64 [JsonIgnore] internal ITrace Trace { get; set; } + private static readonly JsonSerializerOptions CaseInsensitiveOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }; + /// /// Creates a from a JSON element. /// @@ -134,7 +138,23 @@ internal string ResourceBodyBase64 /// The deserialized operation result. internal static DistributedTransactionOperationResult FromJson(JsonElement json) { - return JsonSerializer.Deserialize(json); + DistributedTransactionOperationResult result = JsonSerializer.Deserialize(json, DistributedTransactionOperationResult.CaseInsensitiveOptions); + + if (json.TryGetProperty("resourceBody", out JsonElement resourceBody) + && resourceBody.ValueKind != JsonValueKind.Undefined + && resourceBody.ValueKind != JsonValueKind.Null) + { + // resourceBody is expected to be a JSON object (Cosmos DB document) + if (resourceBody.ValueKind != JsonValueKind.Object) + { + throw new JsonException($"The 'resourceBody' value must be a JSON object, but was '{resourceBody.ValueKind}'."); + } + + byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(resourceBody); + result.ResourceStream = new MemoryStream(bytes, 0, bytes.Length, writable: false, publiclyVisible: true); + } + + return result; } } } diff --git a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionResponse.cs b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionResponse.cs index 20a39ea01e..adedceabe5 100644 --- a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionResponse.cs +++ b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionResponse.cs @@ -314,7 +314,6 @@ private static async Task PopulateFromJsonConten DistributedTransactionOperationResult operationResult = DistributedTransactionOperationResult.FromJson(operationElement); operationResult.Trace = trace; - operationResult.SessionToken ??= responseMessage.Headers.Session; operationResult.ActivityId = responseMessage.Headers.ActivityId; results.Add(operationResult); } @@ -370,7 +369,6 @@ private void CreateAndPopulateResults( this.results.Add(new DistributedTransactionOperationResult(this.StatusCode) { SubStatusCode = this.SubStatusCode, - SessionToken = this.Headers?.Session, ActivityId = this.ActivityId, Trace = trace }); diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/DistributedTransaction/DistributedTransactionE2ETests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/DistributedTransaction/DistributedTransactionE2ETests.cs deleted file mode 100644 index ff3448257c..0000000000 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/DistributedTransaction/DistributedTransactionE2ETests.cs +++ /dev/null @@ -1,732 +0,0 @@ -// ------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------ - -namespace Microsoft.Azure.Cosmos.SDK.EmulatorTests -{ - using System; - using System.Collections.Generic; - using System.IO; - using System.Net; - using System.Text; - using System.Text.Json; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.Azure.Cosmos; - using Microsoft.Azure.Documents; - using Microsoft.VisualStudio.TestTools.UnitTesting; - using PartitionKey = Cosmos.PartitionKey; - - [TestClass] - [DoNotParallelize] - public class DistributedTransactionE2ETests : BaseCosmosClientHelper - { - private const string IdempotencyTokenHeader = HttpConstants.HttpHeaders.IdempotencyToken; - private const string PartitionKeyPath = "/pk"; - - private Container container; - - [TestInitialize] - public async Task TestInitialize() - { - await this.TestInit(); - - ContainerResponse response = await this.database.CreateContainerAsync( - new ContainerProperties(id: Guid.NewGuid().ToString(), partitionKeyPath: PartitionKeyPath), - cancellationToken: this.cancellationToken); - - this.container = response.Container; - } - - [TestCleanup] - public async Task Cleanup() - { - await base.TestCleanup(); - } - - [TestMethod] - public async Task ValidateHappyPathRequestAndResponse() - { - // Arrange - ToDoActivity doc1 = ToDoActivity.CreateRandomToDoActivity(); - ToDoActivity doc2 = ToDoActivity.CreateRandomToDoActivity(); - - DistributedTransactionTestHandler handler = CreateMockHandler( - HttpStatusCode.OK, - CreateMockSuccessResponse(operationCount: 2)); - - using CosmosClient client = TestCommon.CreateCosmosClient( - clientOptions: new CosmosClientOptions - { - CustomHandlers = { handler }, - ConnectionMode = ConnectionMode.Gateway - }); - - // Act - DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() - .CreateItem(this.database.Id, this.container.Id, new PartitionKey(doc1.pk), doc1) - .CreateItem(this.database.Id, this.container.Id, new PartitionKey(doc2.pk), doc2) - .CommitTransactionAsync(CancellationToken.None); - - // Assert - Request - Assert.IsNotNull(handler.CapturedRequest); - Assert.IsNotNull(handler.CapturedRequest.Headers[IdempotencyTokenHeader]); - ValidateRequestBody(handler.CapturedRequestBody, doc1, doc2); - - // Assert - Response - Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); - Assert.IsTrue(response.IsSuccessStatusCode); - Assert.AreEqual(2, response.Count); - - response.Dispose(); - } - - [TestMethod] - public async Task ValidateMixedOperationsRequestStructure() - { - // Arrange - ToDoActivity createDoc = ToDoActivity.CreateRandomToDoActivity(); - ToDoActivity replaceDoc = ToDoActivity.CreateRandomToDoActivity(); - - DistributedTransactionTestHandler handler = CreateMockHandler( - HttpStatusCode.OK, - CreateMockSuccessResponse(operationCount: 3)); - - using CosmosClient client = TestCommon.CreateCosmosClient( - clientOptions: new CosmosClientOptions - { - CustomHandlers = { handler }, - ConnectionMode = ConnectionMode.Gateway - }); - - // Act - DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() - .CreateItem(this.database.Id, this.container.Id, new PartitionKey(createDoc.pk), createDoc) - .ReplaceItem(this.database.Id, this.container.Id, new PartitionKey(replaceDoc.pk), replaceDoc.id, replaceDoc) - .DeleteItem(this.database.Id, this.container.Id, new PartitionKey("delete-pk"), "delete-id") - .CommitTransactionAsync(CancellationToken.None); - - // Assert - using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); - JsonElement operations = requestJson.RootElement.GetProperty("operations"); - - Assert.AreEqual(3, operations.GetArrayLength()); - Assert.AreEqual(OperationType.Create.ToString(), operations[0].GetProperty("operationType").GetString()); // Create - Assert.AreEqual(OperationType.Replace.ToString(), operations[1].GetProperty("operationType").GetString()); // Replace - Assert.AreEqual(OperationType.Delete.ToString(), operations[2].GetProperty("operationType").GetString()); // Delete - - response.Dispose(); - } - - [TestMethod] - public async Task ValidateSerializedRequestFieldDataTypes() - { - // Arrange - ToDoActivity createDoc = ToDoActivity.CreateRandomToDoActivity(); - ToDoActivity replaceDoc = ToDoActivity.CreateRandomToDoActivity(); - - DistributedTransactionTestHandler handler = CreateMockHandler( - HttpStatusCode.OK, - CreateMockSuccessResponse(operationCount: 3)); - - using CosmosClient client = TestCommon.CreateCosmosClient( - clientOptions: new CosmosClientOptions - { - CustomHandlers = { handler }, - ConnectionMode = ConnectionMode.Gateway - }); - - // Act - DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() - .CreateItem(this.database.Id, this.container.Id, new PartitionKey(createDoc.pk), createDoc) - .ReplaceItem(this.database.Id, this.container.Id, new PartitionKey(replaceDoc.pk), replaceDoc.id, replaceDoc) - .DeleteItem(this.database.Id, this.container.Id, new PartitionKey("delete-pk"), "delete-id") - .CommitTransactionAsync(CancellationToken.None); - - // Assert - Parse captured request - using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); - - // Verify root structure - Assert.AreEqual(JsonValueKind.Object, requestJson.RootElement.ValueKind, "Root element should be an object"); - - // Verify operations array - Assert.IsTrue(requestJson.RootElement.TryGetProperty("operations", out JsonElement operations), "operations property should exist"); - Assert.AreEqual(JsonValueKind.Array, operations.ValueKind, "operations should be an array"); - Assert.AreEqual(3, operations.GetArrayLength(), "operations should have 3 elements"); - - // Validate datatypes for each operation - int operationIndex = 0; - foreach (JsonElement operation in operations.EnumerateArray()) - { - // Verify operation is an object - Assert.AreEqual(JsonValueKind.Object, operation.ValueKind, $"Operation {operationIndex} should be an object"); - - (string Property, JsonValueKind Kind)[] requiredFields = - { - ("databaseName", JsonValueKind.String), - ("collectionName", JsonValueKind.String), - ("collectionResourceId", JsonValueKind.String), - ("databaseResourceId", JsonValueKind.String), - ("partitionKey", JsonValueKind.Array), - ("index", JsonValueKind.Number), - ("operationType", JsonValueKind.String), - ("resourceType", JsonValueKind.String) - }; - - foreach ((string property, JsonValueKind expectedKind) in requiredFields) - { - this.ValidateValueKind(operation, property, expectedKind, operationIndex, isRequired: true); - } - - (string Property, JsonValueKind Kind)[] optionalFields = - { - ("id", JsonValueKind.String), - ("resourceBody", JsonValueKind.Object), - ("sessionToken", JsonValueKind.String), - ("etag", JsonValueKind.String), - }; - - foreach ((string property, JsonValueKind expectedKind) in optionalFields) - { - this.ValidateValueKind(operation, property, expectedKind, operationIndex, isRequired: false); - } - - operationIndex++; - } - - response.Dispose(); - } - - [TestMethod] - public async Task ValidateConflictResponseReturnsErrorStatus() - { - // Arrange - string mockErrorResponse = @"{ - ""operationResponses"": [{ - ""index"": 0, - ""statuscode"": 409, - ""substatuscode"": 0 - }] - }"; - - DistributedTransactionTestHandler handler = CreateMockHandler(HttpStatusCode.Conflict, mockErrorResponse); - using CosmosClient client = TestCommon.CreateCosmosClient( - clientOptions: new CosmosClientOptions - { - CustomHandlers = { handler }, - ConnectionMode = ConnectionMode.Gateway - }); - - ToDoActivity doc = ToDoActivity.CreateRandomToDoActivity(); - - // Act - DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() - .CreateItem(this.database.Id, this.container.Id, new PartitionKey(doc.pk), doc) - .CommitTransactionAsync(CancellationToken.None); - - // Assert - Assert.AreEqual(HttpStatusCode.Conflict, response.StatusCode); - Assert.IsFalse(response.IsSuccessStatusCode); - Assert.AreEqual(1, response.Count); - Assert.AreEqual(HttpStatusCode.Conflict, response[0].StatusCode); - - response.Dispose(); - } - - [TestMethod] - public async Task ValidateResponseDeserializesCorrectly() - { - // Arrange - ToDoActivity expectedDoc = ToDoActivity.CreateRandomToDoActivity(); - string base64Body = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(expectedDoc))); - - string mockResponse = $@"{{ - ""operationResponses"": [{{ - ""index"": 0, - ""statuscode"": 201, - ""etag"": ""\""test-etag\"""", - ""resourcebody"": ""{base64Body}"" - }}] - }}"; - - DistributedTransactionTestHandler handler = CreateMockHandler(HttpStatusCode.OK, mockResponse); - using CosmosClient client = TestCommon.CreateCosmosClient( - clientOptions: new CosmosClientOptions - { - CustomHandlers = { handler }, - ConnectionMode = ConnectionMode.Gateway - }); - - // Act - DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() - .CreateItem(this.database.Id, this.container.Id, new PartitionKey(expectedDoc.pk), expectedDoc) - .CommitTransactionAsync(CancellationToken.None); - - // Assert - Assert.AreEqual(HttpStatusCode.Created, response[0].StatusCode); - Assert.AreEqual("\"test-etag\"", response[0].ETag); - Assert.IsNotNull(response[0].ResourceStream); - - using StreamReader reader = new StreamReader(response[0].ResourceStream); - ToDoActivity returnedDoc = JsonSerializer.Deserialize(await reader.ReadToEndAsync()); - - Assert.AreEqual(expectedDoc.id, returnedDoc.id); - Assert.AreEqual(expectedDoc.pk, returnedDoc.pk); - - response.Dispose(); - } - - [TestMethod] - public async Task ValidateReplaceItemWithIfMatchEtagSerializedToRequest() - { - // Arrange - ToDoActivity doc = ToDoActivity.CreateRandomToDoActivity(); - string expectedEtag = "\"test-etag-replace\""; - - DistributedTransactionTestHandler handler = CreateMockHandler( - HttpStatusCode.OK, - CreateMockSuccessResponse(operationCount: 1)); - - using CosmosClient client = TestCommon.CreateCosmosClient( - clientOptions: new CosmosClientOptions - { - CustomHandlers = { handler }, - ConnectionMode = ConnectionMode.Gateway - }); - - // Act - DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() - .ReplaceItem( - this.database.Id, - this.container.Id, - new PartitionKey(doc.pk), - doc.id, - doc, - new DistributedTransactionRequestOptions { IfMatchEtag = expectedEtag }) - .CommitTransactionAsync(CancellationToken.None); - - // Assert - Assert.IsTrue(response.IsSuccessStatusCode); - using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); - JsonElement operation = requestJson.RootElement.GetProperty("operations")[0]; - Assert.IsTrue(operation.TryGetProperty("id", out JsonElement idElement), "id field should be present for replace operation"); - Assert.AreEqual(doc.id, idElement.GetString()); - Assert.IsTrue(operation.TryGetProperty("etag", out JsonElement etagElement), "etag field should be present when IfMatchEtag is set"); - Assert.AreEqual(expectedEtag, etagElement.GetString()); - - response.Dispose(); - } - - [TestMethod] - public async Task ValidateDeleteItemWithIfMatchEtagSerializedToRequest() - { - // Arrange - string expectedEtag = "\"test-etag-delete\""; - - DistributedTransactionTestHandler handler = CreateMockHandler( - HttpStatusCode.OK, - CreateMockSuccessResponse(operationCount: 1)); - - using CosmosClient client = TestCommon.CreateCosmosClient( - clientOptions: new CosmosClientOptions - { - CustomHandlers = { handler }, - ConnectionMode = ConnectionMode.Gateway - }); - - // Act - DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() - .DeleteItem( - this.database.Id, - this.container.Id, - new PartitionKey("delete-pk"), - "delete-id", - new DistributedTransactionRequestOptions { IfMatchEtag = expectedEtag }) - .CommitTransactionAsync(CancellationToken.None); - - // Assert - Assert.IsTrue(response.IsSuccessStatusCode); - using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); - JsonElement operation = requestJson.RootElement.GetProperty("operations")[0]; - Assert.IsTrue(operation.TryGetProperty("id", out JsonElement idElement), "id field should be present for delete operation"); - Assert.AreEqual("delete-id", idElement.GetString()); - Assert.IsTrue(operation.TryGetProperty("etag", out JsonElement etagElement), "etag field should be present when IfMatchEtag is set"); - Assert.AreEqual(expectedEtag, etagElement.GetString()); - - response.Dispose(); - } - - [TestMethod] - public async Task ValidatePatchItemWithIfMatchEtagSerializedToRequest() - { - // Arrange - string expectedEtag = "\"test-etag-patch\""; - IReadOnlyList patchOps = new[] { PatchOperation.Add("/description", "patched") }; - - DistributedTransactionTestHandler handler = CreateMockHandler( - HttpStatusCode.OK, - CreateMockSuccessResponse(operationCount: 1)); - - using CosmosClient client = TestCommon.CreateCosmosClient( - clientOptions: new CosmosClientOptions - { - CustomHandlers = { handler }, - ConnectionMode = ConnectionMode.Gateway - }); - - // Act - DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() - .PatchItem( - this.database.Id, - this.container.Id, - new PartitionKey("patch-pk"), - "patch-id", - patchOps, - new DistributedTransactionRequestOptions { IfMatchEtag = expectedEtag }) - .CommitTransactionAsync(CancellationToken.None); - - // Assert - Assert.IsTrue(response.IsSuccessStatusCode); - using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); - JsonElement operation = requestJson.RootElement.GetProperty("operations")[0]; - Assert.IsTrue(operation.TryGetProperty("id", out JsonElement idElement), "id field should be present for patch operation"); - Assert.AreEqual("patch-id", idElement.GetString()); - Assert.IsTrue(operation.TryGetProperty("etag", out JsonElement etagElement), "etag field should be present when IfMatchEtag is set"); - Assert.AreEqual(expectedEtag, etagElement.GetString()); - - response.Dispose(); - } - - [TestMethod] - public async Task ValidatePreconditionFailedResponse() - { - // Arrange - string mockErrorResponse = @"{ - ""operationResponses"": [{ - ""index"": 0, - ""statuscode"": 412, - ""substatuscode"": 0 - }] - }"; - - DistributedTransactionTestHandler handler = CreateMockHandler(HttpStatusCode.PreconditionFailed, mockErrorResponse); - using CosmosClient client = TestCommon.CreateCosmosClient( - clientOptions: new CosmosClientOptions - { - CustomHandlers = { handler }, - ConnectionMode = ConnectionMode.Gateway - }); - - ToDoActivity doc = ToDoActivity.CreateRandomToDoActivity(); - - // Act - DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() - .ReplaceItem( - this.database.Id, - this.container.Id, - new PartitionKey(doc.pk), - doc.id, - doc, - new DistributedTransactionRequestOptions { IfMatchEtag = "\"stale-etag\"" }) - .CommitTransactionAsync(CancellationToken.None); - - // Assert - Assert.AreEqual(HttpStatusCode.PreconditionFailed, response.StatusCode); - Assert.IsFalse(response.IsSuccessStatusCode); - Assert.AreEqual(1, response.Count); - Assert.AreEqual(HttpStatusCode.PreconditionFailed, response[0].StatusCode); - - response.Dispose(); - } - - [TestMethod] - public async Task ValidateOperationsWithoutIfMatchEtagDoNotSerializeEtagField() - { - // Arrange - ToDoActivity createDoc = ToDoActivity.CreateRandomToDoActivity(); - ToDoActivity replaceDoc = ToDoActivity.CreateRandomToDoActivity(); - - DistributedTransactionTestHandler handler = CreateMockHandler( - HttpStatusCode.OK, - CreateMockSuccessResponse(operationCount: 2)); - - using CosmosClient client = TestCommon.CreateCosmosClient( - clientOptions: new CosmosClientOptions - { - CustomHandlers = { handler }, - ConnectionMode = ConnectionMode.Gateway - }); - - // Act — no IfMatchEtag provided - DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() - .CreateItem(this.database.Id, this.container.Id, new PartitionKey(createDoc.pk), createDoc) - .ReplaceItem(this.database.Id, this.container.Id, new PartitionKey(replaceDoc.pk), replaceDoc.id, replaceDoc) - .CommitTransactionAsync(CancellationToken.None); - - // Assert — no etag field should be serialized when IfMatchEtag is not set - using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); - JsonElement operations = requestJson.RootElement.GetProperty("operations"); - foreach (JsonElement operation in operations.EnumerateArray()) - { - Assert.IsFalse(operation.TryGetProperty("etag", out _), "etag field should not be present when IfMatchEtag is not set"); - } - - response.Dispose(); - } - - [TestMethod] - public async Task ValidateCreateItemStreamOperation() - { - // Arrange - ToDoActivity doc = ToDoActivity.CreateRandomToDoActivity(); - byte[] docBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(doc)); - - DistributedTransactionTestHandler handler = CreateMockHandler( - HttpStatusCode.OK, - CreateMockSuccessResponse(operationCount: 1)); - - using CosmosClient client = TestCommon.CreateCosmosClient( - clientOptions: new CosmosClientOptions - { - CustomHandlers = { handler }, - ConnectionMode = ConnectionMode.Gateway - }); - - // Act - using MemoryStream stream = new MemoryStream(docBytes); - DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() - .CreateItemStream(this.database.Id, this.container.Id, new PartitionKey(doc.pk), stream) - .CommitTransactionAsync(CancellationToken.None); - - // Assert - Assert.IsTrue(response.IsSuccessStatusCode); - using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); - JsonElement operation = requestJson.RootElement.GetProperty("operations")[0]; - Assert.AreEqual(OperationType.Create.ToString(), operation.GetProperty("operationType").GetString()); - JsonElement resourceBody = operation.GetProperty("resourceBody"); - Assert.AreEqual(JsonValueKind.Object, resourceBody.ValueKind); - ToDoActivity actualDoc = JsonSerializer.Deserialize(resourceBody.GetRawText()); - Assert.AreEqual(doc.id, actualDoc.id); - Assert.AreEqual(doc.pk, actualDoc.pk); - - response.Dispose(); - } - - [TestMethod] - public async Task ValidateReplaceItemStreamOperation() - { - // Arrange - ToDoActivity doc = ToDoActivity.CreateRandomToDoActivity(); - byte[] docBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(doc)); - - DistributedTransactionTestHandler handler = CreateMockHandler( - HttpStatusCode.OK, - CreateMockSuccessResponse(operationCount: 1)); - - using CosmosClient client = TestCommon.CreateCosmosClient( - clientOptions: new CosmosClientOptions - { - CustomHandlers = { handler }, - ConnectionMode = ConnectionMode.Gateway - }); - - // Act - using MemoryStream stream = new MemoryStream(docBytes); - DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() - .ReplaceItemStream(this.database.Id, this.container.Id, new PartitionKey(doc.pk), doc.id, stream) - .CommitTransactionAsync(CancellationToken.None); - - // Assert - Assert.IsTrue(response.IsSuccessStatusCode); - using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); - JsonElement operation = requestJson.RootElement.GetProperty("operations")[0]; - Assert.AreEqual(OperationType.Replace.ToString(), operation.GetProperty("operationType").GetString()); - Assert.AreEqual(doc.id, operation.GetProperty("id").GetString()); - JsonElement resourceBody = operation.GetProperty("resourceBody"); - Assert.AreEqual(JsonValueKind.Object, resourceBody.ValueKind); - ToDoActivity actualDoc = JsonSerializer.Deserialize(resourceBody.GetRawText()); - Assert.AreEqual(doc.id, actualDoc.id); - Assert.AreEqual(doc.pk, actualDoc.pk); - - response.Dispose(); - } - - [TestMethod] - public async Task ValidatePatchItemStreamOperation() - { - // Arrange - string patchJson = @"{""operations"":[{""op"":""add"",""path"":""/description"",""value"":""patched""}]}"; - byte[] patchBytes = Encoding.UTF8.GetBytes(patchJson); - - DistributedTransactionTestHandler handler = CreateMockHandler( - HttpStatusCode.OK, - CreateMockSuccessResponse(operationCount: 1)); - - using CosmosClient client = TestCommon.CreateCosmosClient( - clientOptions: new CosmosClientOptions - { - CustomHandlers = { handler }, - ConnectionMode = ConnectionMode.Gateway - }); - - // Act - using MemoryStream stream = new MemoryStream(patchBytes); - DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() - .PatchItemStream(this.database.Id, this.container.Id, new PartitionKey("patch-pk"), "patch-id", stream) - .CommitTransactionAsync(CancellationToken.None); - - // Assert - Assert.IsTrue(response.IsSuccessStatusCode); - using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); - JsonElement operation = requestJson.RootElement.GetProperty("operations")[0]; - Assert.AreEqual(OperationType.Patch.ToString(), operation.GetProperty("operationType").GetString()); - Assert.AreEqual("patch-id", operation.GetProperty("id").GetString()); - - response.Dispose(); - } - - [TestMethod] - public async Task ValidateUpsertItemStreamOperation() - { - // Arrange - ToDoActivity doc = ToDoActivity.CreateRandomToDoActivity(); - byte[] docBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(doc)); - - DistributedTransactionTestHandler handler = CreateMockHandler( - HttpStatusCode.OK, - CreateMockSuccessResponse(operationCount: 1)); - - using CosmosClient client = TestCommon.CreateCosmosClient( - clientOptions: new CosmosClientOptions - { - CustomHandlers = { handler }, - ConnectionMode = ConnectionMode.Gateway - }); - - // Act - using MemoryStream stream = new MemoryStream(docBytes); - DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() - .UpsertItemStream(this.database.Id, this.container.Id, new PartitionKey(doc.pk), stream) - .CommitTransactionAsync(CancellationToken.None); - - // Assert - Assert.IsTrue(response.IsSuccessStatusCode); - using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); - JsonElement operation = requestJson.RootElement.GetProperty("operations")[0]; - Assert.AreEqual(OperationType.Upsert.ToString(), operation.GetProperty("operationType").GetString()); - JsonElement resourceBody = operation.GetProperty("resourceBody"); - Assert.AreEqual(JsonValueKind.Object, resourceBody.ValueKind); - ToDoActivity actualDoc = JsonSerializer.Deserialize(resourceBody.GetRawText()); - Assert.AreEqual(doc.id, actualDoc.id); - Assert.AreEqual(doc.pk, actualDoc.pk); - - response.Dispose(); - } - - #region Helper Methods - - private static DistributedTransactionTestHandler CreateMockHandler(HttpStatusCode statusCode, string responseBody) - { - return new DistributedTransactionTestHandler - { - MockResponseFactory = request => - { - ResponseMessage response = new ResponseMessage(statusCode, request, errorMessage: null) - { - Content = new MemoryStream(Encoding.UTF8.GetBytes(responseBody)) - }; - response.Headers["x-ms-activity-id"] = Guid.NewGuid().ToString(); - response.Headers[IdempotencyTokenHeader] = request.Headers[IdempotencyTokenHeader] ?? Guid.NewGuid().ToString(); - return Task.FromResult(response); - } - }; - } - - private static string CreateMockSuccessResponse(int operationCount) - { - List responses = new(); - for (int i = 0; i < operationCount; i++) - { - responses.Add($@"{{""index"":{i},""statusCode"":201,""etag"":""\""etag-{i}\""""}}"); - } - return $@"{{""operationResponses"":[{string.Join(",", responses)}]}}"; - } - - private static void ValidateRequestBody(string requestBody, params ToDoActivity[] expectedDocs) - { - using JsonDocument json = JsonDocument.Parse(requestBody); - JsonElement operations = json.RootElement.GetProperty("operations"); - - Assert.AreEqual(expectedDocs.Length, operations.GetArrayLength()); - - for (int i = 0; i < expectedDocs.Length; i++) - { - JsonElement op = operations[i]; - - Assert.AreEqual(i, op.GetProperty("index").GetInt32()); - Assert.IsTrue(op.TryGetProperty("databaseName", out _)); - Assert.IsTrue(op.TryGetProperty("collectionName", out _)); - Assert.IsTrue(op.TryGetProperty("operationType", out _)); - - // resourceBody is now a nested JSON object, not a string - JsonElement resourceBody = op.GetProperty("resourceBody"); - Assert.AreEqual(JsonValueKind.Object, resourceBody.ValueKind); - - ToDoActivity actualDoc = JsonSerializer.Deserialize(resourceBody.GetRawText()); - ToDoActivity expectedDoc = expectedDocs[i]; - - Assert.AreEqual(expectedDoc.id, actualDoc.id); - Assert.AreEqual(expectedDoc.pk, actualDoc.pk); - Assert.AreEqual(expectedDoc.taskNum, actualDoc.taskNum); - Assert.AreEqual(expectedDoc.cost, actualDoc.cost); - Assert.AreEqual(expectedDoc.description, actualDoc.description); - } - } - - private void ValidateValueKind(JsonElement operation, string property, JsonValueKind expectedValueKind, int operationIndex, bool isRequired) - { - if (!operation.TryGetProperty(property, out JsonElement value)) - { - Assert.IsFalse(isRequired, $"Operation {operationIndex}: required property '{property}' is missing"); - return; - } - - Assert.AreEqual(expectedValueKind, value.ValueKind, $"Operation {operationIndex}: '{property}' should be {expectedValueKind}"); - } - - #endregion - - #region Test Handler - - private class DistributedTransactionTestHandler : RequestHandler - { - public RequestMessage CapturedRequest { get; private set; } - public string CapturedRequestBody { get; private set; } - public Func> MockResponseFactory { get; set; } - - public override async Task SendAsync(RequestMessage request, CancellationToken cancellationToken) - { - if (request.RequestUriString?.EndsWith("/dtc", StringComparison.OrdinalIgnoreCase) == true) - { - this.CapturedRequest = request; - - if (request.Content != null) - { - using MemoryStream ms = new(); - await request.Content.CopyToAsync(ms); - this.CapturedRequestBody = Encoding.UTF8.GetString(ms.ToArray()); - request.Content.Position = 0; - } - - return this.MockResponseFactory != null - ? await this.MockResponseFactory(request) - : new ResponseMessage(HttpStatusCode.OK, request, errorMessage: null); - } - - return await base.SendAsync(request, cancellationToken); - } - } - - #endregion - } -} diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/DistributedTransaction/DistributedTransactionTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/DistributedTransaction/DistributedTransactionTests.cs new file mode 100644 index 0000000000..60e621ddba --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/DistributedTransaction/DistributedTransactionTests.cs @@ -0,0 +1,814 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.SDK.EmulatorTests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Net; + using System.Text; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Azure.Cosmos; + using Microsoft.Azure.Documents; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using PartitionKey = Cosmos.PartitionKey; + + /// + /// Scenario tests for . + /// + /// These tests use a to intercept the DTC + /// commit request at the handler level while letting all other requests (container creation, + /// RID resolution) flow to the real emulator. This lets us verify the full request/response + /// cycle — serialization, response parsing, idempotency semantics — without requiring the + /// emulator to natively support distributed transactions. + /// + [TestClass] + [DoNotParallelize] + [TestCategory("DistributedTransaction")] + public class DistributedTransactionTests : BaseCosmosClientHelper + { + private const string IdempotencyTokenHeader = HttpConstants.HttpHeaders.IdempotencyToken; + private const string PartitionKeyPath = "/pk"; + + private Container container; + + [TestInitialize] + public async Task TestInitialize() + { + await this.TestInit(); + + ContainerResponse containerResponse = await this.database.CreateContainerAsync( + new ContainerProperties(id: Guid.NewGuid().ToString(), partitionKeyPath: PartitionKeyPath), + cancellationToken: this.cancellationToken); + + this.container = containerResponse.Container; + } + + [TestCleanup] + public new async Task TestCleanup() + { + await base.TestCleanup(); + } + + // Happy path scenarios + + [TestMethod] + [Description("Two creates against the same container both return 201 Created.")] + public async Task CreateItems_SameContainer_AllReturnCreatedStatus() + { + ToDoActivity doc1 = ToDoActivity.CreateRandomToDoActivity(); + ToDoActivity doc2 = ToDoActivity.CreateRandomToDoActivity(); + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(2)))); + + using CosmosClient client = this.CreateMockClient(handler); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(doc1.pk), doc1) + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(doc2.pk), doc2) + .CommitTransactionAsync(CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.IsTrue(response.IsSuccessStatusCode); + Assert.AreEqual(2, response.Count); + Assert.AreEqual(HttpStatusCode.Created, response[0].StatusCode); + Assert.AreEqual(HttpStatusCode.Created, response[1].StatusCode); + + response.Dispose(); + } + + [TestMethod] + [Description("Create, Replace, and Delete operations are all serialized with the correct operationType values.")] + public async Task MixedOperations_AllOperationsAreSerialized() + { + ToDoActivity createDoc = ToDoActivity.CreateRandomToDoActivity(); + ToDoActivity replaceDoc = ToDoActivity.CreateRandomToDoActivity(); + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(3)))); + + using CosmosClient client = this.CreateMockClient(handler); + + await client.CreateDistributedWriteTransaction() + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(createDoc.pk), createDoc) + .ReplaceItem(this.database.Id, this.container.Id, new PartitionKey(replaceDoc.pk), replaceDoc.id, replaceDoc) + .DeleteItem(this.database.Id, this.container.Id, new PartitionKey("delete-pk"), "delete-id") + .CommitTransactionAsync(CancellationToken.None); + + using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); + JsonElement ops = requestJson.RootElement.GetProperty("operations"); + + Assert.AreEqual(3, ops.GetArrayLength()); + Assert.AreEqual(OperationType.Create.ToString(), ops[0].GetProperty("operationType").GetString()); + Assert.AreEqual(OperationType.Replace.ToString(), ops[1].GetProperty("operationType").GetString()); + Assert.AreEqual(OperationType.Delete.ToString(), ops[2].GetProperty("operationType").GetString()); + } + + [TestMethod] + [Description("Upsert alongside a create is serialized as an Upsert operation.")] + public async Task UpsertItem_IncludedInTransaction_SerializesAsUpsertOperation() + { + ToDoActivity createDoc = ToDoActivity.CreateRandomToDoActivity(); + ToDoActivity upsertDoc = ToDoActivity.CreateRandomToDoActivity(); + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(2)))); + + using CosmosClient client = this.CreateMockClient(handler); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(createDoc.pk), createDoc) + .UpsertItem(this.database.Id, this.container.Id, new PartitionKey(upsertDoc.pk), upsertDoc) + .CommitTransactionAsync(CancellationToken.None); + + using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); + JsonElement ops = requestJson.RootElement.GetProperty("operations"); + + Assert.AreEqual(2, ops.GetArrayLength()); + Assert.AreEqual(OperationType.Upsert.ToString(), ops[1].GetProperty("operationType").GetString()); + Assert.AreEqual(2, response.Count); + + response.Dispose(); + } + + [TestMethod] + [Description("Patch operation is serialized and included in the transaction.")] + public async Task PatchItem_WithAddOperation_IncludedInTransaction() + { + ToDoActivity createDoc = ToDoActivity.CreateRandomToDoActivity(); + IReadOnlyList patchOps = new[] { PatchOperation.Add("/description", "patched") }; + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(2)))); + + using CosmosClient client = this.CreateMockClient(handler); + + await client.CreateDistributedWriteTransaction() + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(createDoc.pk), createDoc) + .PatchItem(this.database.Id, this.container.Id, new PartitionKey("patch-pk"), "item-to-patch", patchOps) + .CommitTransactionAsync(CancellationToken.None); + + using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); + JsonElement ops = requestJson.RootElement.GetProperty("operations"); + + Assert.AreEqual(2, ops.GetArrayLength()); + Assert.AreEqual(OperationType.Patch.ToString(), ops[1].GetProperty("operationType").GetString()); + } + + [TestMethod] + [Description("Operations targeting two different containers are both serialized with their respective container names.")] + public async Task CrossContainer_TwoDifferentContainers_AllOperationsCommitted() + { + ContainerResponse secondContainerResponse = await this.database.CreateContainerAsync( + new ContainerProperties(id: Guid.NewGuid().ToString(), partitionKeyPath: PartitionKeyPath), + cancellationToken: this.cancellationToken); + + Container secondContainer = secondContainerResponse.Container; + + ToDoActivity doc1 = ToDoActivity.CreateRandomToDoActivity(); + ToDoActivity doc2 = ToDoActivity.CreateRandomToDoActivity(); + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(2)))); + + using CosmosClient client = this.CreateMockClient(handler); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(doc1.pk), doc1) + .CreateItem(this.database.Id, secondContainer.Id, new PartitionKey(doc2.pk), doc2) + .CommitTransactionAsync(CancellationToken.None); + + using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); + JsonElement ops = requestJson.RootElement.GetProperty("operations"); + + Assert.AreEqual(2, ops.GetArrayLength()); + Assert.AreNotEqual( + ops[0].GetProperty("collectionName").GetString(), + ops[1].GetProperty("collectionName").GetString(), + "Operations should reference different containers."); + Assert.AreEqual(2, response.Count); + + response.Dispose(); + } + + // Response properties + + [TestMethod] + [Description("The idempotency token sent in the request header is echoed back in the response.")] + public async Task CommitAsync_ResponseContainsIdempotencyToken() + { + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler(request => + { + string token = request.Headers[IdempotencyTokenHeader] ?? Guid.NewGuid().ToString(); + ResponseMessage mockResponse = this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(1)); + mockResponse.Headers[IdempotencyTokenHeader] = token; + return Task.FromResult(mockResponse); + }); + + using CosmosClient client = this.CreateMockClient(handler); + ToDoActivity doc = ToDoActivity.CreateRandomToDoActivity(); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(doc.pk), doc) + .CommitTransactionAsync(CancellationToken.None); + + Assert.AreNotEqual(Guid.Empty, response.IdempotencyToken, "Response must carry the idempotency token."); + + response.Dispose(); + } + + [TestMethod] + [Description("Each operation result's Index matches its position in the request operation list.")] + public async Task EachResult_HasIndex_MatchingOperationOrder() + { + ToDoActivity doc1 = ToDoActivity.CreateRandomToDoActivity(); + ToDoActivity doc2 = ToDoActivity.CreateRandomToDoActivity(); + ToDoActivity doc3 = ToDoActivity.CreateRandomToDoActivity(); + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(3)))); + + using CosmosClient client = this.CreateMockClient(handler); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(doc1.pk), doc1) + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(doc2.pk), doc2) + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(doc3.pk), doc3) + .CommitTransactionAsync(CancellationToken.None); + + Assert.AreEqual(3, response.Count); + for (int i = 0; i < response.Count; i++) + { + Assert.AreEqual(i, response[i].Index, $"Result at position {i} should have Index = {i}."); + } + + response.Dispose(); + } + + [TestMethod] + [Description("When the server includes a resource body, it is accessible as a readable stream on the result.")] + public async Task SuccessfulCreate_ResponseContainsResourceBody() + { + ToDoActivity expectedDoc = ToDoActivity.CreateRandomToDoActivity(); + string resourceBodyJson = JsonSerializer.Serialize(expectedDoc); + + string mockResponseJson = $@"{{ + ""operationResponses"": [{{ + ""index"": 0, + ""statusCode"": 201, + ""etag"": ""\""test-etag\"""", + ""resourceBody"": {resourceBodyJson} + }}] + }}"; + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, mockResponseJson))); + + using CosmosClient client = this.CreateMockClient(handler); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(expectedDoc.pk), expectedDoc) + .CommitTransactionAsync(CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.Created, response[0].StatusCode); + Assert.AreEqual("\"test-etag\"", response[0].ETag); + Assert.IsNotNull(response[0].ResourceStream, "Resource stream should be populated when resourcebody is present."); + + using StreamReader reader = new StreamReader(response[0].ResourceStream); + ToDoActivity returnedDoc = JsonSerializer.Deserialize(await reader.ReadToEndAsync()); + + Assert.AreEqual(expectedDoc.id, returnedDoc.id); + Assert.AreEqual(expectedDoc.pk, returnedDoc.pk); + + response.Dispose(); + } + + // Error handling + + [TestMethod] + [Description("A 409 Conflict response marks the transaction and the failing operation as not successful.")] + public async Task ConflictResponse_ReturnsFailureStatus() + { + string mockErrorJson = @"{ + ""operationResponses"": [{ + ""index"": 0, + ""statusCode"": 409, + ""subStatusCode"": 0 + }] + }"; + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.Conflict, mockErrorJson))); + + using CosmosClient client = this.CreateMockClient(handler); + ToDoActivity doc = ToDoActivity.CreateRandomToDoActivity(); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(doc.pk), doc) + .CommitTransactionAsync(CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.Conflict, response.StatusCode); + Assert.IsFalse(response.IsSuccessStatusCode); + Assert.AreEqual(1, response.Count); + Assert.AreEqual(HttpStatusCode.Conflict, response[0].StatusCode); + + response.Dispose(); + } + + [TestMethod] + [Description("A 404 Not Found on a replace operation marks the transaction as failed.")] + public async Task NotFoundResponse_OnReplaceItem_ReturnsFailureStatus() + { + string mockErrorJson = @"{ + ""operationResponses"": [{ + ""index"": 0, + ""statusCode"": 404, + ""subStatusCode"": 0 + }] + }"; + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.NotFound, mockErrorJson))); + + using CosmosClient client = this.CreateMockClient(handler); + ToDoActivity doc = ToDoActivity.CreateRandomToDoActivity(); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .ReplaceItem(this.database.Id, this.container.Id, new PartitionKey(doc.pk), doc.id, doc) + .CommitTransactionAsync(CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); + Assert.IsFalse(response.IsSuccessStatusCode); + Assert.AreEqual(HttpStatusCode.NotFound, response[0].StatusCode); + + response.Dispose(); + } + + [TestMethod] + [Description("A 207 MultiStatus response promotes the first failing operation's status code and all results are present.")] + public async Task MultiStatusResponse_PartialFailure_AllResultsPresent() + { + // One success (index 0) and one failure (index 1) → MultiStatus 207 + string mockMultiStatusJson = @"{ + ""operationResponses"": [ + { ""index"": 0, ""statusCode"": 201 }, + { ""index"": 1, ""statusCode"": 409, ""subStatusCode"": 0 } + ] + }"; + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse((HttpStatusCode)207, mockMultiStatusJson))); + + using CosmosClient client = this.CreateMockClient(handler); + ToDoActivity doc1 = ToDoActivity.CreateRandomToDoActivity(); + ToDoActivity doc2 = ToDoActivity.CreateRandomToDoActivity(); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(doc1.pk), doc1) + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(doc2.pk), doc2) + .CommitTransactionAsync(CancellationToken.None); + + // All results must be present regardless of partial failure + Assert.AreEqual(2, response.Count, "Response must contain a result for every operation."); + Assert.IsFalse(response.IsSuccessStatusCode, "Partial failure should make the overall response unsuccessful."); + + response.Dispose(); + } + + // Serialization + + [TestMethod] + [Description("All required fields are present with the correct JSON value kind across Create, Replace, and Delete operations; optional fields that appear also have the correct kind.")] + public async Task SerializedRequest_AllOperations_CorrectFieldTypes() + { + ToDoActivity createDoc = ToDoActivity.CreateRandomToDoActivity(); + ToDoActivity replaceDoc = ToDoActivity.CreateRandomToDoActivity(); + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(3)))); + + using CosmosClient client = this.CreateMockClient(handler); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(createDoc.pk), createDoc) + .ReplaceItem(this.database.Id, this.container.Id, new PartitionKey(replaceDoc.pk), replaceDoc.id, replaceDoc) + .DeleteItem(this.database.Id, this.container.Id, new PartitionKey("delete-pk"), "delete-id") + .CommitTransactionAsync(CancellationToken.None); + + using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); + + Assert.AreEqual(JsonValueKind.Object, requestJson.RootElement.ValueKind, "Root element should be an object"); + Assert.IsTrue(requestJson.RootElement.TryGetProperty("operations", out JsonElement operations), "operations property should exist"); + Assert.AreEqual(JsonValueKind.Array, operations.ValueKind, "operations should be an array"); + Assert.AreEqual(3, operations.GetArrayLength(), "operations should have 3 elements"); + + int operationIndex = 0; + foreach (JsonElement operation in operations.EnumerateArray()) + { + Assert.AreEqual(JsonValueKind.Object, operation.ValueKind, $"Operation {operationIndex} should be an object"); + + (string Property, JsonValueKind Kind)[] requiredFields = + { + ("databaseName", JsonValueKind.String), + ("collectionName", JsonValueKind.String), + ("collectionResourceId", JsonValueKind.String), + ("databaseResourceId", JsonValueKind.String), + ("partitionKey", JsonValueKind.Array), + ("index", JsonValueKind.Number), + ("operationType", JsonValueKind.String), + ("resourceType", JsonValueKind.String) + }; + + foreach ((string property, JsonValueKind expectedKind) in requiredFields) + { + this.ValidateValueKind(operation, property, expectedKind, operationIndex, isRequired: true); + } + + (string Property, JsonValueKind Kind)[] optionalFields = + { + ("id", JsonValueKind.String), + ("resourceBody", JsonValueKind.Object), + ("sessionToken", JsonValueKind.String), + ("etag", JsonValueKind.String), + }; + + foreach ((string property, JsonValueKind expectedKind) in optionalFields) + { + this.ValidateValueKind(operation, property, expectedKind, operationIndex, isRequired: false); + } + + operationIndex++; + } + + response.Dispose(); + } + + // ETag conditions + + [TestMethod] + [Description("A replace operation with IfMatchEtag set serializes the etag field to the request.")] + public async Task ReplaceItem_WithIfMatchEtag_EtagSerializedToRequest() + { + ToDoActivity doc = ToDoActivity.CreateRandomToDoActivity(); + string expectedEtag = "\"test-etag-replace\""; + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(1)))); + + using CosmosClient client = this.CreateMockClient(handler); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .ReplaceItem( + this.database.Id, + this.container.Id, + new PartitionKey(doc.pk), + doc.id, + doc, + new DistributedTransactionRequestOptions { IfMatchEtag = expectedEtag }) + .CommitTransactionAsync(CancellationToken.None); + + Assert.IsTrue(response.IsSuccessStatusCode); + using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); + JsonElement operation = requestJson.RootElement.GetProperty("operations")[0]; + Assert.IsTrue(operation.TryGetProperty("id", out JsonElement idElement), "id field should be present for replace operation"); + Assert.AreEqual(doc.id, idElement.GetString()); + Assert.IsTrue(operation.TryGetProperty("etag", out JsonElement etagElement), "etag field should be present when IfMatchEtag is set"); + Assert.AreEqual(expectedEtag, etagElement.GetString()); + + response.Dispose(); + } + + [TestMethod] + [Description("A delete operation with IfMatchEtag set serializes the etag field to the request.")] + public async Task DeleteItem_WithIfMatchEtag_EtagSerializedToRequest() + { + string expectedEtag = "\"test-etag-delete\""; + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(1)))); + + using CosmosClient client = this.CreateMockClient(handler); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .DeleteItem( + this.database.Id, + this.container.Id, + new PartitionKey("delete-pk"), + "delete-id", + new DistributedTransactionRequestOptions { IfMatchEtag = expectedEtag }) + .CommitTransactionAsync(CancellationToken.None); + + Assert.IsTrue(response.IsSuccessStatusCode); + using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); + JsonElement operation = requestJson.RootElement.GetProperty("operations")[0]; + Assert.IsTrue(operation.TryGetProperty("id", out JsonElement idElement), "id field should be present for delete operation"); + Assert.AreEqual("delete-id", idElement.GetString()); + Assert.IsTrue(operation.TryGetProperty("etag", out JsonElement etagElement), "etag field should be present when IfMatchEtag is set"); + Assert.AreEqual(expectedEtag, etagElement.GetString()); + + response.Dispose(); + } + + [TestMethod] + [Description("A patch operation with IfMatchEtag set serializes the etag field to the request.")] + public async Task PatchItem_WithIfMatchEtag_EtagSerializedToRequest() + { + string expectedEtag = "\"test-etag-patch\""; + IReadOnlyList patchOps = new[] { PatchOperation.Add("/description", "patched") }; + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(1)))); + + using CosmosClient client = this.CreateMockClient(handler); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .PatchItem( + this.database.Id, + this.container.Id, + new PartitionKey("patch-pk"), + "patch-id", + patchOps, + new DistributedTransactionRequestOptions { IfMatchEtag = expectedEtag }) + .CommitTransactionAsync(CancellationToken.None); + + Assert.IsTrue(response.IsSuccessStatusCode); + using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); + JsonElement operation = requestJson.RootElement.GetProperty("operations")[0]; + Assert.IsTrue(operation.TryGetProperty("id", out JsonElement idElement), "id field should be present for patch operation"); + Assert.AreEqual("patch-id", idElement.GetString()); + Assert.IsTrue(operation.TryGetProperty("etag", out JsonElement etagElement), "etag field should be present when IfMatchEtag is set"); + Assert.AreEqual(expectedEtag, etagElement.GetString()); + + response.Dispose(); + } + + [TestMethod] + [Description("A 412 Precondition Failed response marks the transaction and the failing operation as not successful.")] + public async Task PreconditionFailedResponse_OnReplaceWithStaleEtag_ReturnsFailureStatus() + { + string mockErrorJson = @"{ + ""operationResponses"": [{ + ""index"": 0, + ""statusCode"": 412, + ""subStatusCode"": 0 + }] + }"; + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.PreconditionFailed, mockErrorJson))); + + using CosmosClient client = this.CreateMockClient(handler); + ToDoActivity doc = ToDoActivity.CreateRandomToDoActivity(); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .ReplaceItem( + this.database.Id, + this.container.Id, + new PartitionKey(doc.pk), + doc.id, + doc, + new DistributedTransactionRequestOptions { IfMatchEtag = "\"stale-etag\"" }) + .CommitTransactionAsync(CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.PreconditionFailed, response.StatusCode); + Assert.IsFalse(response.IsSuccessStatusCode); + Assert.AreEqual(1, response.Count); + Assert.AreEqual(HttpStatusCode.PreconditionFailed, response[0].StatusCode); + + response.Dispose(); + } + + [TestMethod] + [Description("Operations without IfMatchEtag set do not include an etag field in the serialized request.")] + public async Task Operations_WithoutIfMatchEtag_NoEtagFieldSerialized() + { + ToDoActivity createDoc = ToDoActivity.CreateRandomToDoActivity(); + ToDoActivity replaceDoc = ToDoActivity.CreateRandomToDoActivity(); + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(2)))); + + using CosmosClient client = this.CreateMockClient(handler); + + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .CreateItem(this.database.Id, this.container.Id, new PartitionKey(createDoc.pk), createDoc) + .ReplaceItem(this.database.Id, this.container.Id, new PartitionKey(replaceDoc.pk), replaceDoc.id, replaceDoc) + .CommitTransactionAsync(CancellationToken.None); + + using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); + JsonElement ops = requestJson.RootElement.GetProperty("operations"); + foreach (JsonElement operation in ops.EnumerateArray()) + { + Assert.IsFalse(operation.TryGetProperty("etag", out _), "etag field should not be present when IfMatchEtag is not set"); + } + + response.Dispose(); + } + + // Stream operations + + [TestMethod] + [Description("CreateItemStream serializes the stream payload as a JSON object resourceBody in the request.")] + public async Task CreateItemStream_ValidDocument_SerializedAsCreateOperation() + { + ToDoActivity doc = ToDoActivity.CreateRandomToDoActivity(); + byte[] docBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(doc)); + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(1)))); + + using CosmosClient client = this.CreateMockClient(handler); + + using MemoryStream stream = new MemoryStream(docBytes); + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .CreateItemStream(this.database.Id, this.container.Id, new PartitionKey(doc.pk), stream) + .CommitTransactionAsync(CancellationToken.None); + + Assert.IsTrue(response.IsSuccessStatusCode); + using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); + JsonElement operation = requestJson.RootElement.GetProperty("operations")[0]; + Assert.AreEqual(OperationType.Create.ToString(), operation.GetProperty("operationType").GetString()); + JsonElement resourceBody = operation.GetProperty("resourceBody"); + Assert.AreEqual(JsonValueKind.Object, resourceBody.ValueKind); + ToDoActivity actualDoc = JsonSerializer.Deserialize(resourceBody.GetRawText()); + Assert.AreEqual(doc.id, actualDoc.id); + Assert.AreEqual(doc.pk, actualDoc.pk); + + response.Dispose(); + } + + [TestMethod] + [Description("ReplaceItemStream serializes the stream payload as a JSON object resourceBody and includes the item id in the request.")] + public async Task ReplaceItemStream_ValidDocument_SerializedAsReplaceOperation() + { + ToDoActivity doc = ToDoActivity.CreateRandomToDoActivity(); + byte[] docBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(doc)); + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(1)))); + + using CosmosClient client = this.CreateMockClient(handler); + + using MemoryStream stream = new MemoryStream(docBytes); + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .ReplaceItemStream(this.database.Id, this.container.Id, new PartitionKey(doc.pk), doc.id, stream) + .CommitTransactionAsync(CancellationToken.None); + + Assert.IsTrue(response.IsSuccessStatusCode); + using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); + JsonElement operation = requestJson.RootElement.GetProperty("operations")[0]; + Assert.AreEqual(OperationType.Replace.ToString(), operation.GetProperty("operationType").GetString()); + Assert.AreEqual(doc.id, operation.GetProperty("id").GetString()); + JsonElement resourceBody = operation.GetProperty("resourceBody"); + Assert.AreEqual(JsonValueKind.Object, resourceBody.ValueKind); + ToDoActivity actualDoc = JsonSerializer.Deserialize(resourceBody.GetRawText()); + Assert.AreEqual(doc.id, actualDoc.id); + Assert.AreEqual(doc.pk, actualDoc.pk); + + response.Dispose(); + } + + [TestMethod] + [Description("PatchItemStream serializes the patch payload and includes the item id in the request.")] + public async Task PatchItemStream_ValidPatch_SerializedAsPatchOperation() + { + string patchJson = @"{""operations"":[{""op"":""add"",""path"":""/description"",""value"":""patched""}]}"; + byte[] patchBytes = Encoding.UTF8.GetBytes(patchJson); + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(1)))); + + using CosmosClient client = this.CreateMockClient(handler); + + using MemoryStream stream = new MemoryStream(patchBytes); + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .PatchItemStream(this.database.Id, this.container.Id, new PartitionKey("patch-pk"), "patch-id", stream) + .CommitTransactionAsync(CancellationToken.None); + + Assert.IsTrue(response.IsSuccessStatusCode); + using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); + JsonElement operation = requestJson.RootElement.GetProperty("operations")[0]; + Assert.AreEqual(OperationType.Patch.ToString(), operation.GetProperty("operationType").GetString()); + Assert.AreEqual("patch-id", operation.GetProperty("id").GetString()); + + response.Dispose(); + } + + [TestMethod] + [Description("UpsertItemStream serializes the stream payload as a JSON object resourceBody in the request.")] + public async Task UpsertItemStream_ValidDocument_SerializedAsUpsertOperation() + { + ToDoActivity doc = ToDoActivity.CreateRandomToDoActivity(); + byte[] docBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(doc)); + + DistributedTransactionMockHandler handler = new DistributedTransactionMockHandler( + request => Task.FromResult(this.BuildMockResponse(HttpStatusCode.OK, BuildSuccessResponseJson(1)))); + + using CosmosClient client = this.CreateMockClient(handler); + + using MemoryStream stream = new MemoryStream(docBytes); + DistributedTransactionResponse response = await client.CreateDistributedWriteTransaction() + .UpsertItemStream(this.database.Id, this.container.Id, new PartitionKey(doc.pk), stream) + .CommitTransactionAsync(CancellationToken.None); + + Assert.IsTrue(response.IsSuccessStatusCode); + using JsonDocument requestJson = JsonDocument.Parse(handler.CapturedRequestBody); + JsonElement operation = requestJson.RootElement.GetProperty("operations")[0]; + Assert.AreEqual(OperationType.Upsert.ToString(), operation.GetProperty("operationType").GetString()); + JsonElement resourceBody = operation.GetProperty("resourceBody"); + Assert.AreEqual(JsonValueKind.Object, resourceBody.ValueKind); + ToDoActivity actualDoc = JsonSerializer.Deserialize(resourceBody.GetRawText()); + Assert.AreEqual(doc.id, actualDoc.id); + Assert.AreEqual(doc.pk, actualDoc.pk); + + response.Dispose(); + } + + // Helpers + + private void ValidateValueKind(JsonElement operation, string property, JsonValueKind expectedValueKind, int operationIndex, bool isRequired) + { + if (!operation.TryGetProperty(property, out JsonElement value)) + { + Assert.IsFalse(isRequired, $"Operation {operationIndex}: required property '{property}' is missing"); + return; + } + + Assert.AreEqual(expectedValueKind, value.ValueKind, $"Operation {operationIndex}: '{property}' should be {expectedValueKind}"); + } + + private CosmosClient CreateMockClient(DistributedTransactionMockHandler handler) + { + return TestCommon.CreateCosmosClient(clientOptions: new CosmosClientOptions + { + CustomHandlers = { handler }, + ConnectionMode = ConnectionMode.Gateway + }); + } + + private ResponseMessage BuildMockResponse(HttpStatusCode statusCode, string responseBody) + { + ResponseMessage response = new ResponseMessage(statusCode) + { + Content = new MemoryStream(Encoding.UTF8.GetBytes(responseBody)) + }; + response.Headers["x-ms-activity-id"] = Guid.NewGuid().ToString(); + return response; + } + + private static string BuildSuccessResponseJson(int operationCount) + { + List results = new List(); + for (int i = 0; i < operationCount; i++) + { + results.Add($@"{{""index"":{i},""statusCode"":201,""etag"":""\""etag-{i}\""""}}"); + } + + return $@"{{""operationResponses"":[{string.Join(",", results)}]}}"; + } + + // Mock handler + + /// + /// Intercepts DTC commit requests (URLs ending in "/dtc"), captures the serialized + /// request body, and returns the response produced by . + /// All other requests are forwarded to the next handler in the pipeline (the emulator). + /// + private class DistributedTransactionMockHandler : RequestHandler + { + private readonly Func> mockResponseFactory; + + public string CapturedRequestBody { get; private set; } + + public DistributedTransactionMockHandler(Func> mockResponseFactory) + { + this.mockResponseFactory = mockResponseFactory; + } + + public override async Task SendAsync( + RequestMessage request, + CancellationToken cancellationToken) + { + if (request.RequestUriString?.EndsWith("/dtc", StringComparison.OrdinalIgnoreCase) == true) + { + if (request.Content != null) + { + using MemoryStream ms = new MemoryStream(); + await request.Content.CopyToAsync(ms); + this.CapturedRequestBody = Encoding.UTF8.GetString(ms.ToArray()); + request.Content.Position = 0; + } + + return await this.mockResponseFactory(request); + } + + return await base.SendAsync(request, cancellationToken); + } + } + } +} diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionResponseTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionResponseTests.cs new file mode 100644 index 0000000000..9e16e3a81c --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionResponseTests.cs @@ -0,0 +1,698 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Net; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Azure.Cosmos.Tracing; + using Microsoft.Azure.Documents; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using PartitionKey = Cosmos.PartitionKey; + + /// + /// Unit tests for covering null/malformed content, + /// count-mismatch paths, MultiStatus promotion, idempotency-token resolution, + /// IDisposable semantics, and the IsSuccessStatusCode boundaries. + /// + [TestClass] + public class DistributedTransactionResponseTests + { + // Null or malformed content + + [TestMethod] + [Description("When the response has no content body and the HTTP status is success, the SDK must return 500 because the server should always return a body on success.")] + public async Task FromResponseMessage_NullContent_SuccessStatus_ReturnsInternalServerError() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); + + ResponseMessage responseMessage = new ResponseMessage(HttpStatusCode.OK) + { + Content = null + }; + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.IsFalse(response.IsSuccessStatusCode); + } + + [TestMethod] + [Description("When the response has no content body and the HTTP status is an error, results should be padded with the error status code.")] + public async Task FromResponseMessage_NullContent_ErrorStatus_PopulatesResultsWithErrorStatus() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 2); + + ResponseMessage responseMessage = new ResponseMessage(HttpStatusCode.Conflict) + { + Content = null + }; + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.Conflict, response.StatusCode); + Assert.IsFalse(response.IsSuccessStatusCode); + Assert.AreEqual(2, response.Count, "Count must equal the number of submitted operations."); + + for (int i = 0; i < response.Count; i++) + { + Assert.AreEqual(HttpStatusCode.Conflict, response[i].StatusCode, + $"Result[{i}] should carry the transaction-level error status."); + } + } + + [TestMethod] + [Description("A 412 PreconditionFailed response with one matching result must return PreconditionFailed status, IsSuccessStatusCode false, Count 1, and result[0].StatusCode PreconditionFailed.")] + public async Task FromResponseMessage_PreconditionFailed_ReturnsFailureStatus() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); + + string json = $@"{{""operationResponses"":[{{""index"":0,""statusCode"":{(int)HttpStatusCode.PreconditionFailed}}}]}}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.PreconditionFailed, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.PreconditionFailed, response.StatusCode); + Assert.IsFalse(response.IsSuccessStatusCode); + Assert.AreEqual(1, response.Count); + Assert.AreEqual(HttpStatusCode.PreconditionFailed, response[0].StatusCode); + } + + [TestMethod] + [Description("When the response body contains malformed JSON and the HTTP status is success, the SDK must return 500.")] + public async Task FromResponseMessage_MalformedJson_SuccessStatus_ReturnsInternalServerError() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); + + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, "{invalid-json"); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.IsFalse(response.IsSuccessStatusCode); + } + + [TestMethod] + [Description("When the response body contains malformed JSON and the HTTP status is an error, results are padded with the error status code.")] + public async Task FromResponseMessage_MalformedJson_ErrorStatus_PopulatesResultsWithErrorStatus() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 2); + + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.Conflict, "{invalid-json"); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.Conflict, response.StatusCode); + Assert.AreEqual(2, response.Count); + + for (int i = 0; i < response.Count; i++) + { + Assert.AreEqual(HttpStatusCode.Conflict, response[i].StatusCode); + } + } + + // Count mismatch + + [TestMethod] + [Description("When the server returns fewer results than submitted operations and the HTTP status is success, the SDK must return 500.")] + public async Task FromResponseMessage_CountMismatch_FewerResults_SuccessStatus_Returns500() + { + // 2 operations submitted but server returns only 1 result + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 2); + + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.IsFalse(response.IsSuccessStatusCode); + } + + [TestMethod] + [Description("When the server returns fewer results than submitted operations and the HTTP status is an error, results are padded.")] + public async Task FromResponseMessage_CountMismatch_FewerResults_ErrorStatus_PadsResults() + { + // 3 operations submitted but server returns only 1 result + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 3); + + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":409}]}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.Conflict, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.Conflict, response.StatusCode); + Assert.AreEqual(3, response.Count, "Count must equal the number of submitted operations."); + + for (int i = 0; i < response.Count; i++) + { + Assert.AreEqual(HttpStatusCode.Conflict, response[i].StatusCode); + } + } + + // MultiStatus promotion + + [TestMethod] + [Description("A 207 MultiStatus response promotes the status code of the first failing operation to the overall response status.")] + public async Task FromResponseMessage_MultiStatus_PromotesFirstNonDependencyFailure() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 2); + + // index 0 succeeds, index 1 fails with 409 + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201},{""index"":1,""statusCode"":409}]}"; + ResponseMessage responseMessage = BuildResponseMessage((HttpStatusCode)StatusCodes.MultiStatus, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.Conflict, response.StatusCode, + "The overall status should be promoted from 207 to the first failing operation status (409)."); + Assert.IsFalse(response.IsSuccessStatusCode); + Assert.AreEqual(2, response.Count); + } + + [TestMethod] + [Description("A 207 MultiStatus response scans past leading successes to find and promote the first failing operation status.")] + public async Task FromResponseMessage_MultiStatus_SkipsSuccessesBeforeFirstFailure() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 3); + + // index 0 and 1 succeed; index 2 fails with 503 + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201},{""index"":1,""statusCode"":200},{""index"":2,""statusCode"":503}]}"; + ResponseMessage responseMessage = BuildResponseMessage((HttpStatusCode)StatusCodes.MultiStatus, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, response.StatusCode, + "Promotion must scan past successes (201, 200) and find the first error (503)."); + Assert.IsFalse(response.IsSuccessStatusCode); + } + + [TestMethod] + [Description("When all operations in a 207 response report FailedDependency (424), no promotion occurs and the overall status remains 207.")] + public async Task FromResponseMessage_MultiStatus_AllFailedDependency_StatusRemainsMultiStatus() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 2); + + // Both results are FailedDependency (424) — excluded from promotion logic + string json = $@"{{""operationResponses"":[{{""index"":0,""statusCode"":{(int)StatusCodes.FailedDependency}}},{{""index"":1,""statusCode"":{(int)StatusCodes.FailedDependency}}}]}}"; + ResponseMessage responseMessage = BuildResponseMessage((HttpStatusCode)StatusCodes.MultiStatus, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual((HttpStatusCode)StatusCodes.MultiStatus, response.StatusCode, + "Status must remain 207 when all operation results are FailedDependency (excluded from promotion)."); + } + + // Idempotency token resolution + + [TestMethod] + [Description("When the IdempotencyToken header is absent from the response, the request token is used as the fallback.")] + public async Task FromResponseMessage_IdempotencyToken_MissingFromHeader_FallsBackToRequestToken() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); + Guid requestToken = Guid.NewGuid(); + + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + // No IdempotencyToken header added + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + requestToken, + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(requestToken, response.IdempotencyToken, + "The request token must be used when the response header is absent."); + } + + [TestMethod] + [Description("When the IdempotencyToken response header contains a non-GUID value, the SDK falls back to the request token.")] + public async Task FromResponseMessage_IdempotencyToken_InvalidGuidInHeader_FallsBackToRequestToken() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); + Guid requestToken = Guid.NewGuid(); + + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + responseMessage.Headers.Add(HttpConstants.HttpHeaders.IdempotencyToken, "not-a-valid-guid"); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + requestToken, + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(requestToken, response.IdempotencyToken, + "An unparseable header value must fall back to the request token."); + } + + // IDisposable and ObjectDisposed + + [TestMethod] + [Description("Dispose() must set result ResourceStreams to null so callers cannot accidentally use a closed stream.")] + public async Task Dispose_ReleasesResultResourceStreams() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); + + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201,""resourceBody"":{""id"":""item1""}}]}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.IsNotNull(response[0].ResourceStream, "ResourceStream should be populated from resourcebody before Dispose."); + + response.Dispose(); + + // After dispose, result list is nulled — indexer throws ObjectDisposedException + Assert.ThrowsException(() => _ = response[0]); + } + + [TestMethod] + [Description("Calling Dispose() a second time must be a safe no-op.")] + public async Task Dispose_SecondCall_DoesNotThrow() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + response.Dispose(); + response.Dispose(); // must not throw + } + + [TestMethod] + [Description("Accessing a result by index after Dispose() must throw ObjectDisposedException.")] + public async Task Indexer_AfterDispose_ThrowsObjectDisposedException() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + response.Dispose(); + + Assert.ThrowsException(() => _ = response[0]); + } + + // IsSuccessStatusCode boundaries + + [TestMethod] + [Description("HTTP 200 is a success status code.")] + public async Task IsSuccessStatusCode_200_ReturnsTrue() + { + DistributedTransactionResponse response = await BuildResponseWithStatusAsync(HttpStatusCode.OK, operationCount: 1); + Assert.IsTrue(response.IsSuccessStatusCode); + } + + [TestMethod] + [Description("HTTP 299 is the last success status code.")] + public async Task IsSuccessStatusCode_299_ReturnsTrue() + { + DistributedTransactionResponse response = await BuildResponseWithStatusAsync((HttpStatusCode)299, operationCount: 1); + Assert.IsTrue(response.IsSuccessStatusCode); + } + + [TestMethod] + [Description("HTTP 300 is not a success status code.")] + public async Task IsSuccessStatusCode_300_ReturnsFalse() + { + DistributedTransactionResponse response = await BuildResponseWithStatusAsync((HttpStatusCode)300, operationCount: 1, isError: true); + Assert.IsFalse(response.IsSuccessStatusCode); + } + + [TestMethod] + [Description("HTTP 199 is not a success status code.")] + public async Task IsSuccessStatusCode_199_ReturnsFalse() + { + DistributedTransactionResponse response = await BuildResponseWithStatusAsync((HttpStatusCode)199, operationCount: 1, isError: true); + Assert.IsFalse(response.IsSuccessStatusCode); + } + + // Indexer and enumerator + + [TestMethod] + [Description("Accessing a valid index returns the expected result with the correct StatusCode and Index.")] + public async Task Indexer_ValidIndex_ReturnsExpectedResult() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 2); + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201},{""index"":1,""statusCode"":200}]}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.Created, response[0].StatusCode); + Assert.AreEqual(0, response[0].Index); + Assert.AreEqual(HttpStatusCode.OK, response[1].StatusCode); + Assert.AreEqual(1, response[1].Index); + } + + [TestMethod] + [Description("Accessing a negative index must throw ArgumentOutOfRangeException.")] + public async Task Indexer_NegativeIndex_ThrowsArgumentOutOfRangeException() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.ThrowsException(() => _ = response[-1]); + } + + [TestMethod] + [Description("Accessing index equal to Count must throw ArgumentOutOfRangeException.")] + public async Task Indexer_IndexEqualsCount_ThrowsArgumentOutOfRangeException() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.ThrowsException(() => _ = response[response.Count]); + } + + [TestMethod] + [Description("GetEnumerator yields all results in the same order as index access.")] + public async Task GetEnumerator_ReturnsAllResults_InOrder() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 3); + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201},{""index"":1,""statusCode"":200},{""index"":2,""statusCode"":204}]}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + List enumerated = new List(); + foreach (DistributedTransactionOperationResult result in response) + { + enumerated.Add(result); + } + + Assert.AreEqual(3, enumerated.Count); + for (int i = 0; i < response.Count; i++) + { + Assert.AreEqual(response[i].StatusCode, enumerated[i].StatusCode, + $"Enumerator result[{i}] must match indexer result[{i}]."); + Assert.AreEqual(response[i].Index, enumerated[i].Index); + } + } + + [TestMethod] + [Description("Count matches the number of parsed operation results in the response JSON.")] + public async Task Count_ReturnsNumberOfParsedResults() + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 4); + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201},{""index"":1,""statusCode"":201},{""index"":2,""statusCode"":201},{""index"":3,""statusCode"":201}]}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(4, response.Count); + } + + // Operation result property deserialization + + [TestMethod] + [Description("SubStatusCodeValue deserializes from the 'subStatusCode' JSON property and is accessible as both uint and SubStatusCodes enum.")] + public async Task FromResponseMessage_OperationResult_SubStatusCode_DeserializesCorrectly() + { + const uint expectedSubStatusCode = 449; // RetryWith + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); + + string json = $@"{{""operationResponses"":[{{""index"":0,""statusCode"":449,""subStatusCode"":{expectedSubStatusCode}}}]}}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(expectedSubStatusCode, response[0].SubStatusCodeValue, + "SubStatusCodeValue must equal the uint value from the JSON 'subStatusCode' field."); + Assert.AreEqual((SubStatusCodes)expectedSubStatusCode, response[0].SubStatusCode, + "SubStatusCode enum must be the cast of the uint value."); + } + + [TestMethod] + [Description("RequestCharge deserializes from the 'requestCharge' JSON property.")] + public async Task FromResponseMessage_OperationResult_RequestCharge_DeserializesCorrectly() + { + const double expectedRequestCharge = 5.43; + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); + + string json = $@"{{""operationResponses"":[{{""index"":0,""statusCode"":201,""requestCharge"":{expectedRequestCharge}}}]}}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(expectedRequestCharge, response[0].RequestCharge, + "RequestCharge must equal the value from the JSON 'requestCharge' field."); + } + + [TestMethod] + [Description("ETag deserializes from the 'etag' JSON property.")] + public async Task FromResponseMessage_OperationResult_ETag_DeserializesCorrectly() + { + const string expectedETag = "etag-value-abc123"; + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); + + string json = $@"{{""operationResponses"":[{{""index"":0,""statusCode"":201,""etag"":""{expectedETag}""}}]}}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(expectedETag, response[0].ETag, + "ETag must equal the value from the JSON 'etag' field."); + } + + [TestMethod] + [Description("SessionToken deserializes from the 'sessionToken' JSON property.")] + public async Task FromResponseMessage_OperationResult_SessionToken_DeserializesCorrectly() + { + const string expectedSessionToken = "0:12345"; + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); + + string json = $@"{{""operationResponses"":[{{""index"":0,""statusCode"":201,""sessionToken"":""{expectedSessionToken}""}}]}}"; + ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); + + DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( + responseMessage, + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + + Assert.AreEqual(expectedSessionToken, response[0].SessionToken, + "SessionToken must equal the value from the JSON 'sessionToken' field."); + } + + // Helpers + + /// + /// Builds a with + /// simple Create operations (no resource body — safe for response-parsing tests). + /// + private static async Task BuildServerRequestAsync(int operationCount) + { + List operations = new List(); + for (int i = 0; i < operationCount; i++) + { + operations.Add(new DistributedTransactionOperation( + operationType: OperationType.Create, + operationIndex: i, + database: "testDb", + container: "testContainer", + partitionKey: new PartitionKey($"pk{i}"))); + } + + return await DistributedTransactionServerRequest.CreateAsync( + operations, + MockCosmosUtil.Serializer, + CancellationToken.None); + } + + /// + /// Builds a with the given status code and JSON body. + /// + private static ResponseMessage BuildResponseMessage(HttpStatusCode statusCode, string json) + { + return new ResponseMessage(statusCode) + { + Content = new MemoryStream(Encoding.UTF8.GetBytes(json)) + }; + } + + /// + /// Builds a driven by the given status code. + /// For success codes the JSON must have matching result count; for error codes the results are padded. + /// + private static async Task BuildResponseWithStatusAsync( + HttpStatusCode statusCode, + int operationCount, + bool isError = false) + { + DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount); + + string json; + if (isError) + { + // Produce intentionally mismatched JSON so the padded-results path is exercised + json = $@"{{""operationResponses"":[{{""index"":0,""statusCode"":{(int)statusCode}}}]}}"; + } + else + { + List results = new List(); + for (int i = 0; i < operationCount; i++) + { + results.Add($@"{{""index"":{i},""statusCode"":{(int)statusCode}}}"); + } + json = $@"{{""operationResponses"":[{string.Join(",", results)}]}}"; + } + + return await DistributedTransactionResponse.FromResponseMessageAsync( + BuildResponseMessage(statusCode, json), + serverRequest, + MockCosmosUtil.Serializer, + Guid.NewGuid(), + NoOpTrace.Singleton, + CancellationToken.None); + } + } +} diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionSerializerTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionSerializerTests.cs new file mode 100644 index 0000000000..0f0b851813 --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionSerializerTests.cs @@ -0,0 +1,485 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Azure.Cosmos.Tracing; + using Microsoft.Azure.Documents; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Moq; + using PartitionKey = Cosmos.PartitionKey; + + /// + /// Unit tests verifying which fields (id, resourceBody) are present or absent + /// in the serialized request body for each distributed-transaction operation type. + /// + /// Tests go through using the same mock-context + /// intercept pattern as , so the assertions + /// cover the full chain: argument → operation → serialization. + /// + [TestClass] + public class DistributedTransactionSerializerTests + { + private const string Database = "testDb"; + private const string Container = "testContainer"; + + // id field presence per operation type + + [TestMethod] + [Description("CreateItem does not set an explicit id field on the operation, so 'id' must be absent from the serialized JSON.")] + public async Task CreateItem_SerializedBody_HasResourceBody_NoIdField() + { + string capturedJson = await this.CaptureCommitBodyAsync(tx => + tx.CreateItem(Database, Container, new PartitionKey("pk"), new TestItem("create-item"))); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.IsFalse(op.TryGetProperty("id", out _), + "Create operation must NOT include an 'id' field in the serialized body."); + Assert.IsTrue(op.TryGetProperty("resourceBody", out _), + "Create operation must include a 'resourceBody' field."); + } + + [TestMethod] + [Description("ReplaceItem sets both id and resource, so both 'id' and 'resourceBody' must appear in the serialized JSON.")] + public async Task ReplaceItem_SerializedBody_HasIdField_AndResourceBody() + { + const string itemId = "replace-item-id"; + + string capturedJson = await this.CaptureCommitBodyAsync(tx => + tx.ReplaceItem(Database, Container, new PartitionKey("pk"), itemId, new TestItem(itemId))); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.IsTrue(op.TryGetProperty("id", out _), + "Replace operation must include an 'id' field."); + Assert.IsTrue(op.TryGetProperty("resourceBody", out _), + "Replace operation must include a 'resourceBody' field."); + } + + [TestMethod] + [Description("DeleteItem sets id but has no resource body, so 'id' must be present and 'resourceBody' must be absent.")] + public async Task DeleteItem_SerializedBody_HasIdField_NoResourceBody() + { + const string itemId = "delete-item-id"; + + string capturedJson = await this.CaptureCommitBodyAsync(tx => + tx.DeleteItem(Database, Container, new PartitionKey("pk"), itemId)); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.IsTrue(op.TryGetProperty("id", out _), + "Delete operation must include an 'id' field."); + Assert.IsFalse(op.TryGetProperty("resourceBody", out _), + "Delete operation must NOT include a 'resourceBody' field."); + } + + [TestMethod] + [Description("UpsertItem provides a resource but no explicit id, so 'resourceBody' must be present and 'id' must be absent.")] + public async Task UpsertItem_SerializedBody_HasResourceBody_NoIdField() + { + string capturedJson = await this.CaptureCommitBodyAsync(tx => + tx.UpsertItem(Database, Container, new PartitionKey("pk"), new TestItem("upsert-item"))); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.IsFalse(op.TryGetProperty("id", out _), + "Upsert operation must NOT include an 'id' field."); + Assert.IsTrue(op.TryGetProperty("resourceBody", out _), + "Upsert operation must include a 'resourceBody' field."); + } + + // id value correctness + + [TestMethod] + [Description("The 'id' field in the serialized JSON for ReplaceItem must exactly match the id passed to ReplaceItem().")] + public async Task ReplaceItem_SerializedBody_IdValueMatchesProvided() + { + const string expectedId = "exact-replace-id"; + + string capturedJson = await this.CaptureCommitBodyAsync(tx => + tx.ReplaceItem(Database, Container, new PartitionKey("pk"), expectedId, new TestItem(expectedId))); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.IsTrue(op.TryGetProperty("id", out JsonElement idElement)); + Assert.AreEqual(expectedId, idElement.GetString(), + "The serialized 'id' field must equal the id passed to ReplaceItem()."); + } + + [TestMethod] + [Description("The 'id' field in the serialized JSON for DeleteItem must exactly match the id passed to DeleteItem().")] + public async Task DeleteItem_SerializedBody_IdValueMatchesProvided() + { + const string expectedId = "exact-delete-id"; + + string capturedJson = await this.CaptureCommitBodyAsync(tx => + tx.DeleteItem(Database, Container, new PartitionKey("pk"), expectedId)); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.IsTrue(op.TryGetProperty("id", out JsonElement idElement)); + Assert.AreEqual(expectedId, idElement.GetString(), + "The serialized 'id' field must equal the id passed to DeleteItem()."); + } + + // resourceBody content + + [TestMethod] + [Description("The 'resourceBody' field for a CreateItem must be valid, parseable JSON.")] + public async Task CreateItem_SerializedBody_ResourceBodyIsValidJson() + { + string capturedJson = await this.CaptureCommitBodyAsync(tx => + tx.CreateItem(Database, Container, new PartitionKey("pk"), new TestItem("json-check"))); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.IsTrue(op.TryGetProperty("resourceBody", out JsonElement resourceBodyElement), + "resourceBody must be present."); + Assert.AreEqual(JsonValueKind.Object, resourceBodyElement.ValueKind, + "resourceBody must be a valid JSON object."); + } + + // operationType and resourceType contracts + + [TestMethod] + [Description("Each of the five operation types must serialize the 'operationType' field with the matching enum name.")] + public async Task AllOpTypes_SerializedBody_OperationTypeMatchesOpType() + { + IReadOnlyList patchOps = new[] { PatchOperation.Add("/value", "v") }; + + string capturedJson = await this.CaptureCommitBodyAsync(tx => + tx.CreateItem(Database, Container, new PartitionKey("pk"), new TestItem("c")) + .ReplaceItem(Database, Container, new PartitionKey("pk"), "r", new TestItem("r")) + .DeleteItem(Database, Container, new PartitionKey("pk"), "d") + .UpsertItem(Database, Container, new PartitionKey("pk"), new TestItem("u")) + .PatchItem(Database, Container, new PartitionKey("pk"), "p", patchOps), + expectedResultCount: 5); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement ops = doc.RootElement.GetProperty("operations"); + + Assert.AreEqual(5, ops.GetArrayLength()); + Assert.AreEqual(OperationType.Create.ToString(), ops[0].GetProperty("operationType").GetString()); + Assert.AreEqual(OperationType.Replace.ToString(), ops[1].GetProperty("operationType").GetString()); + Assert.AreEqual(OperationType.Delete.ToString(), ops[2].GetProperty("operationType").GetString()); + Assert.AreEqual(OperationType.Upsert.ToString(), ops[3].GetProperty("operationType").GetString()); + Assert.AreEqual(OperationType.Patch.ToString(), ops[4].GetProperty("operationType").GetString()); + } + + [TestMethod] + [Description("Every operation in the serialized body must have 'resourceType' set to 'Document' regardless of the operation type.")] + public async Task AllOpTypes_SerializedBody_ResourceTypeIsAlwaysDocument() + { + IReadOnlyList patchOps = new[] { PatchOperation.Add("/value", "v") }; + + string capturedJson = await this.CaptureCommitBodyAsync(tx => + tx.CreateItem(Database, Container, new PartitionKey("pk"), new TestItem("c")) + .ReplaceItem(Database, Container, new PartitionKey("pk"), "r", new TestItem("r")) + .DeleteItem(Database, Container, new PartitionKey("pk"), "d") + .UpsertItem(Database, Container, new PartitionKey("pk"), new TestItem("u")) + .PatchItem(Database, Container, new PartitionKey("pk"), "p", patchOps), + expectedResultCount: 5); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement ops = doc.RootElement.GetProperty("operations"); + + for (int i = 0; i < ops.GetArrayLength(); i++) + { + Assert.AreEqual( + ResourceType.Document.ToString(), + ops[i].GetProperty("resourceType").GetString(), + $"Operation[{i}] must have resourceType = 'Document'."); + } + } + + // field type contracts + + [TestMethod] + [Description("Every field present in a serialized operation must carry the correct JSON value type")] + public async Task SerializedOperation_AllPresentFields_HaveCorrectJsonValueKinds() + { + const string itemId = "type-check-id"; + + string capturedJson = await this.CaptureCommitBodyAsync(tx => + tx.ReplaceItem(Database, Container, new PartitionKey("pk"), itemId, new TestItem(itemId))); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.AreEqual(JsonValueKind.String, op.GetProperty("databaseName").ValueKind, "'databaseName' must be a JSON string."); + Assert.AreEqual(JsonValueKind.String, op.GetProperty("collectionName").ValueKind, "'collectionName' must be a JSON string."); + Assert.AreEqual(JsonValueKind.String, op.GetProperty("collectionResourceId").ValueKind, "'collectionResourceId' must be a JSON string."); + Assert.AreEqual(JsonValueKind.String, op.GetProperty("databaseResourceId").ValueKind, "'databaseResourceId' must be a JSON string."); + Assert.AreEqual(JsonValueKind.String, op.GetProperty("id").ValueKind, "'id' must be a JSON string."); + Assert.AreEqual(JsonValueKind.Array, op.GetProperty("partitionKey").ValueKind, "'partitionKey' must be a JSON array, not a quoted string."); + Assert.AreEqual(JsonValueKind.Number, op.GetProperty("index").ValueKind, "'index' must be a JSON number, not a quoted string."); + Assert.AreEqual(JsonValueKind.Object, op.GetProperty("resourceBody").ValueKind, "'resourceBody' must be a JSON object."); + Assert.AreEqual(JsonValueKind.String, op.GetProperty("operationType").ValueKind, "'operationType' must be a JSON string."); + Assert.AreEqual(JsonValueKind.String, op.GetProperty("resourceType").ValueKind, "'resourceType' must be a JSON string."); + } + + // Stream operations + + [TestMethod] + [Description("CreateItemStream with a JSON stream sets resourceBody on the operation; no explicit 'id' field should appear.")] + public async Task CreateItemStream_SerializedBody_HasResourceBody_NoIdField() + { + byte[] docBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new TestItem("test-id"))); + using MemoryStream stream = new MemoryStream(docBytes); + string capturedJson = await this.CaptureCommitBodyAsync( + tx => tx.CreateItemStream(Database, Container, new PartitionKey("pk"), stream)); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.IsFalse(op.TryGetProperty("id", out _), + "CreateItemStream operation must NOT include an 'id' field in the serialized body."); + Assert.IsTrue(op.TryGetProperty("resourceBody", out _), + "CreateItemStream operation must include a 'resourceBody' field."); + } + + [TestMethod] + [Description("ReplaceItemStream sets both id and resource; 'id' and 'resourceBody' must appear in the serialized JSON.")] + public async Task ReplaceItemStream_SerializedBody_HasIdAndResourceBody() + { + const string itemId = "replace-stream-id"; + byte[] docBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new TestItem(itemId))); + using MemoryStream stream = new MemoryStream(docBytes); + string capturedJson = await this.CaptureCommitBodyAsync( + tx => tx.ReplaceItemStream(Database, Container, new PartitionKey("pk"), itemId, stream)); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.IsTrue(op.TryGetProperty("id", out _), + "ReplaceItemStream operation must include an 'id' field."); + Assert.IsTrue(op.TryGetProperty("resourceBody", out _), + "ReplaceItemStream operation must include a 'resourceBody' field."); + } + + [TestMethod] + [Description("PatchItemStream sets id and resource; 'id' and 'resourceBody' must appear in the serialized JSON.")] + public async Task PatchItemStream_SerializedBody_HasIdAndResourceBody() + { + const string itemId = "patch-stream-id"; + byte[] patchBytes = Encoding.UTF8.GetBytes(@"{""operations"":[{""op"":""add"",""path"":""/description"",""value"":""patched""}]}"); + using MemoryStream stream = new MemoryStream(patchBytes); + string capturedJson = await this.CaptureCommitBodyAsync( + tx => tx.PatchItemStream(Database, Container, new PartitionKey("pk"), itemId, stream)); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.IsTrue(op.TryGetProperty("id", out _), + "PatchItemStream operation must include an 'id' field."); + Assert.IsTrue(op.TryGetProperty("resourceBody", out _), + "PatchItemStream operation must include a 'resourceBody' field."); + } + + [TestMethod] + [Description("UpsertItemStream sets resource but no explicit id; 'resourceBody' must be present and 'id' must be absent.")] + public async Task UpsertItemStream_SerializedBody_HasResourceBody_NoIdField() + { + byte[] docBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new TestItem("upsert-stream-id"))); + using MemoryStream stream = new MemoryStream(docBytes); + string capturedJson = await this.CaptureCommitBodyAsync( + tx => tx.UpsertItemStream(Database, Container, new PartitionKey("pk"), stream)); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.IsFalse(op.TryGetProperty("id", out _), + "UpsertItemStream operation must NOT include an 'id' field in the serialized body."); + Assert.IsTrue(op.TryGetProperty("resourceBody", out _), + "UpsertItemStream operation must include a 'resourceBody' field."); + } + + // IfMatchEtag + + [TestMethod] + [Description("ReplaceItem with IfMatchEtag set must serialize an 'etag' field in the operation JSON.")] + public async Task ReplaceItem_WithIfMatchEtag_SerializesEtagField() + { + const string etag = "\"test-etag\""; + const string itemId = "etag-replace-id"; + + string capturedJson = await this.CaptureCommitBodyAsync(tx => + tx.ReplaceItem(Database, Container, new PartitionKey("pk"), itemId, new TestItem(itemId), + new DistributedTransactionRequestOptions { IfMatchEtag = etag })); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.IsTrue(op.TryGetProperty("etag", out JsonElement etagElement), + "Replace operation with IfMatchEtag must include an 'etag' field."); + Assert.AreEqual(etag, etagElement.GetString()); + } + + [TestMethod] + [Description("DeleteItem with IfMatchEtag set must serialize an 'etag' field in the operation JSON.")] + public async Task DeleteItem_WithIfMatchEtag_SerializesEtagField() + { + const string etag = "\"test-etag\""; + const string itemId = "etag-delete-id"; + + string capturedJson = await this.CaptureCommitBodyAsync(tx => + tx.DeleteItem(Database, Container, new PartitionKey("pk"), itemId, + new DistributedTransactionRequestOptions { IfMatchEtag = etag })); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.IsTrue(op.TryGetProperty("etag", out JsonElement etagElement), + "Delete operation with IfMatchEtag must include an 'etag' field."); + Assert.AreEqual(etag, etagElement.GetString()); + } + + [TestMethod] + [Description("PatchItem with IfMatchEtag set must serialize an 'etag' field in the operation JSON.")] + public async Task PatchItem_WithIfMatchEtag_SerializesEtagField() + { + const string etag = "\"test-etag\""; + const string itemId = "etag-patch-id"; + + string capturedJson = await this.CaptureCommitBodyAsync(tx => + tx.PatchItem(Database, Container, new PartitionKey("pk"), itemId, + new[] { PatchOperation.Add("/value", "v") }, + new DistributedTransactionRequestOptions { IfMatchEtag = etag })); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement op = doc.RootElement.GetProperty("operations")[0]; + + Assert.IsTrue(op.TryGetProperty("etag", out JsonElement etagElement), + "Patch operation with IfMatchEtag must include an 'etag' field."); + Assert.AreEqual(etag, etagElement.GetString()); + } + + [TestMethod] + [Description("Operations without IfMatchEtag must not include an 'etag' field in the serialized JSON for any operation.")] + public async Task Operations_WithoutIfMatchEtag_DoNotIncludeEtagField() + { + string capturedJson = await this.CaptureCommitBodyAsync(tx => + tx.CreateItem(Database, Container, new PartitionKey("pk"), new TestItem("create-no-etag")) + .ReplaceItem(Database, Container, new PartitionKey("pk"), "replace-no-etag", new TestItem("replace-no-etag")), + expectedResultCount: 2); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement ops = doc.RootElement.GetProperty("operations"); + + for (int i = 0; i < ops.GetArrayLength(); i++) + { + Assert.IsFalse(ops[i].TryGetProperty("etag", out _), + $"Operation[{i}] without IfMatchEtag must NOT include an 'etag' field."); + } + } + + // Helpers + + /// + /// Builds a transaction using , intercepts the HTTP + /// commit call, captures the serialized JSON body, and returns it. + /// + private async Task CaptureCommitBodyAsync( + Func buildTransaction, + int expectedResultCount = 1) + { + string capturedJson = null; + + Mock contextMock = this.BuildContextSetup(); + contextMock + .Setup(c => c.ProcessResourceOperationStreamAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns, ITrace, CancellationToken>( + (uri, resType, opType, opts, container, pk, itemId, stream, enricher, trace, ct) => + { + using MemoryStream ms = new MemoryStream(); + stream.CopyTo(ms); + capturedJson = Encoding.UTF8.GetString(ms.ToArray()); + return Task.FromResult(this.BuildSuccessResponse(expectedResultCount)); + }); + + DistributedWriteTransaction tx = new DistributedWriteTransactionCore(contextMock.Object); + await buildTransaction(tx).CommitTransactionAsync(CancellationToken.None); + + Assert.IsNotNull(capturedJson, "The commit body was not captured — the mock was not invoked."); + return capturedJson; + } + + private Mock BuildContextSetup() + { + ContainerProperties containerProps = ContainerProperties.CreateWithResourceId("ccZ1ANCszwk="); + containerProps.PartitionKeyPath = "/pk"; + + Mock contextMock = new Mock(); + + contextMock + .Setup(c => c.SerializerCore) + .Returns(MockCosmosUtil.Serializer); + + contextMock + .Setup(c => c.GetCachedContainerPropertiesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(containerProps); + + return contextMock; + } + + private ResponseMessage BuildSuccessResponse(int operationCount) + { + List results = new List(); + for (int i = 0; i < operationCount; i++) + { + results.Add($@"{{""index"":{i},""statusCode"":201}}"); + } + + string json = $@"{{""operationResponses"":[{string.Join(",", results)}]}}"; + return new ResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new MemoryStream(Encoding.UTF8.GetBytes(json)) + }; + } + + private sealed class TestItem + { + [System.Text.Json.Serialization.JsonPropertyName("id")] + public string Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("value")] + public string Value { get; set; } + + public TestItem(string id) + { + this.Id = id; + this.Value = "test-value"; + } + } + } +} diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedWriteTransactionTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedWriteTransactionTests.cs new file mode 100644 index 0000000000..dc8d0f2e0b --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedWriteTransactionTests.cs @@ -0,0 +1,431 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Net; + using System.Net.Http; + using System.Text; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Azure.Cosmos.Tracing; + using Microsoft.Azure.Documents; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Moq; + using PartitionKey = Cosmos.PartitionKey; + + /// + /// Unit tests for covering argument validation, + /// request structure, and response parsing. Uses a mocked + /// so no emulator is required. + /// + [TestClass] + public class DistributedWriteTransactionTests + { + private const string Database = "testDb"; + private const string Container = "testContainer"; + + // Argument validation + + [TestMethod] + public void CreateItem_NullDatabase_ThrowsArgumentException() + { + DistributedWriteTransaction tx = this.NewTransaction(); + Assert.ThrowsException( + () => tx.CreateItem(null, Container, new PartitionKey("pk"), new TestItem())); + } + + [TestMethod] + public void CreateItem_NullCollection_ThrowsArgumentException() + { + DistributedWriteTransaction tx = this.NewTransaction(); + Assert.ThrowsException( + () => tx.CreateItem(Database, null, new PartitionKey("pk"), new TestItem())); + } + + [TestMethod] + public void CreateItem_NullResource_ThrowsArgumentException() + { + DistributedWriteTransaction tx = this.NewTransaction(); + Assert.ThrowsException( + () => tx.CreateItem(Database, Container, new PartitionKey("pk"), null)); + } + + [TestMethod] + public void ReplaceItem_NullId_ThrowsArgumentException() + { + DistributedWriteTransaction tx = this.NewTransaction(); + Assert.ThrowsException( + () => tx.ReplaceItem(Database, Container, new PartitionKey("pk"), null, new TestItem())); + } + + [TestMethod] + public void DeleteItem_EmptyId_ThrowsArgumentException() + { + DistributedWriteTransaction tx = this.NewTransaction(); + Assert.ThrowsException( + () => tx.DeleteItem(Database, Container, new PartitionKey("pk"), string.Empty)); + } + + [TestMethod] + public void PatchItem_NullPatchOperations_ThrowsArgumentNullException() + { + DistributedWriteTransaction tx = this.NewTransaction(); + Assert.ThrowsException( + () => tx.PatchItem(Database, Container, new PartitionKey("pk"), "item-id", null)); + } + + [TestMethod] + public void PatchItem_EmptyPatchOperations_ThrowsArgumentNullException() + { + DistributedWriteTransaction tx = this.NewTransaction(); + Assert.ThrowsException( + () => tx.PatchItem(Database, Container, new PartitionKey("pk"), "item-id", new List())); + } + + [TestMethod] + public void CreateItemStream_NullStream_ThrowsArgumentNullException() + { + DistributedWriteTransaction tx = this.NewTransaction(); + Assert.ThrowsException( + () => tx.CreateItemStream(Database, Container, new PartitionKey("pk"), null)); + } + + [TestMethod] + public void ReplaceItemStream_NullStream_ThrowsArgumentNullException() + { + DistributedWriteTransaction tx = this.NewTransaction(); + Assert.ThrowsException( + () => tx.ReplaceItemStream(Database, Container, new PartitionKey("pk"), "item-id", null)); + } + + [TestMethod] + public void PatchItemStream_NullStream_ThrowsArgumentNullException() + { + DistributedWriteTransaction tx = this.NewTransaction(); + Assert.ThrowsException( + () => tx.PatchItemStream(Database, Container, new PartitionKey("pk"), "item-id", null)); + } + + [TestMethod] + public void UpsertItemStream_NullStream_ThrowsArgumentNullException() + { + DistributedWriteTransaction tx = this.NewTransaction(); + Assert.ThrowsException( + () => tx.UpsertItemStream(Database, Container, new PartitionKey("pk"), null)); + } + + // Request structure + + [TestMethod] + public async Task CommitAsync_SendsCorrectOperationAndResourceType() + { + ResourceType capturedResourceType = default; + OperationType capturedOperationType = default; + + Mock contextMock = this.BuildContextSetup(); + contextMock + .Setup(c => c.ProcessResourceOperationStreamAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns, ITrace, CancellationToken>( + (uri, resType, opType, opts, container, pk, itemId, stream, enricher, trace, ct) => + { + capturedResourceType = resType; + capturedOperationType = opType; + return Task.FromResult(BuildSuccessResponse(1)); + }); + + await new DistributedWriteTransactionCore(contextMock.Object) + .CreateItem(Database, Container, new PartitionKey("pk"), new TestItem()) + .CommitTransactionAsync(CancellationToken.None); + + Assert.AreEqual(ResourceType.DistributedTransactionBatch, capturedResourceType); + Assert.AreEqual(OperationType.CommitDistributedTransaction, capturedOperationType); + } + + [TestMethod] + public async Task CommitAsync_SetsIdempotencyTokenHeader() + { + string capturedToken = null; + + Mock contextMock = this.BuildContextSetup(); + contextMock + .Setup(c => c.ProcessResourceOperationStreamAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns, ITrace, CancellationToken>( + (uri, resType, opType, opts, container, pk, itemId, stream, enricher, trace, ct) => + { + RequestMessage req = new RequestMessage(); + enricher?.Invoke(req); + capturedToken = req.Headers[HttpConstants.HttpHeaders.IdempotencyToken]; + return Task.FromResult(BuildSuccessResponse(1)); + }); + + await new DistributedWriteTransactionCore(contextMock.Object) + .CreateItem(Database, Container, new PartitionKey("pk"), new TestItem()) + .CommitTransactionAsync(CancellationToken.None); + + Assert.IsNotNull(capturedToken, "Idempotency token header must be set."); + Assert.IsTrue(Guid.TryParse(capturedToken, out _), "Idempotency token must be a valid GUID."); + } + + [TestMethod] + public async Task CommitAsync_OperationIndexIsZeroBasedAndOrdered() + { + string capturedJson = null; + + Mock contextMock = this.BuildContextSetup(); + contextMock + .Setup(c => c.ProcessResourceOperationStreamAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns, ITrace, CancellationToken>( + (uri, resType, opType, opts, container, pk, itemId, stream, enricher, trace, ct) => + { + using MemoryStream ms = new MemoryStream(); + stream.CopyTo(ms); + capturedJson = Encoding.UTF8.GetString(ms.ToArray()); + return Task.FromResult(BuildSuccessResponse(3)); + }); + + await new DistributedWriteTransactionCore(contextMock.Object) + .CreateItem(Database, Container, new PartitionKey("pk1"), new TestItem("id1")) + .ReplaceItem(Database, Container, new PartitionKey("pk2"), "id2", new TestItem("id2")) + .DeleteItem(Database, Container, new PartitionKey("pk3"), "id3") + .CommitTransactionAsync(CancellationToken.None); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement ops = doc.RootElement.GetProperty("operations"); + + for (int i = 0; i < ops.GetArrayLength(); i++) + { + Assert.AreEqual(i, ops[i].GetProperty("index").GetInt32(), + $"Operation at position {i} should have index {i}."); + } + } + + [TestMethod] + public async Task CommitAsync_AllFiveOperationTypes_AreIncluded() + { + string capturedJson = null; + + Mock contextMock = this.BuildContextSetup(); + contextMock + .Setup(c => c.ProcessResourceOperationStreamAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns, ITrace, CancellationToken>( + (uri, resType, opType, opts, container, pk, itemId, stream, enricher, trace, ct) => + { + using MemoryStream ms = new MemoryStream(); + stream.CopyTo(ms); + capturedJson = Encoding.UTF8.GetString(ms.ToArray()); + return Task.FromResult(BuildSuccessResponse(5)); + }); + + await new DistributedWriteTransactionCore(contextMock.Object) + .CreateItem(Database, Container, new PartitionKey("pk"), new TestItem("create")) + .ReplaceItem(Database, Container, new PartitionKey("pk"), "replace", new TestItem("replace")) + .DeleteItem(Database, Container, new PartitionKey("pk"), "delete") + .PatchItem(Database, Container, new PartitionKey("pk"), "patch", new[] { PatchOperation.Add("/value", "v") }) + .UpsertItem(Database, Container, new PartitionKey("pk"), new TestItem("upsert")) + .CommitTransactionAsync(CancellationToken.None); + + using JsonDocument doc = JsonDocument.Parse(capturedJson); + JsonElement ops = doc.RootElement.GetProperty("operations"); + + Assert.AreEqual(5, ops.GetArrayLength()); + + HashSet opTypes = new HashSet(); + foreach (JsonElement op in ops.EnumerateArray()) + { + opTypes.Add(op.GetProperty("operationType").GetString()); + } + + Assert.IsTrue(opTypes.Contains(OperationType.Create.ToString())); + Assert.IsTrue(opTypes.Contains(OperationType.Replace.ToString())); + Assert.IsTrue(opTypes.Contains(OperationType.Delete.ToString())); + Assert.IsTrue(opTypes.Contains(OperationType.Patch.ToString())); + Assert.IsTrue(opTypes.Contains(OperationType.Upsert.ToString())); + } + + [TestMethod] + public async Task CommitAsync_SuccessResponse_ReturnsResultsForAllOperations() + { + Mock contextMock = this.BuildContextSetup(); + contextMock + .Setup(c => c.ProcessResourceOperationStreamAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(BuildSuccessResponse(2)); + + DistributedTransactionResponse response = await new DistributedWriteTransactionCore(contextMock.Object) + .CreateItem(Database, Container, new PartitionKey("pk1"), new TestItem("id1")) + .CreateItem(Database, Container, new PartitionKey("pk2"), new TestItem("id2")) + .CommitTransactionAsync(CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.IsTrue(response.IsSuccessStatusCode); + Assert.AreEqual(2, response.Count); + } + + [TestMethod] + public async Task CommitAsync_ErrorResponse_ReturnsIsSuccessStatusCodeFalse() + { + Mock contextMock = this.BuildContextSetup(); + contextMock + .Setup(c => c.ProcessResourceOperationStreamAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(BuildErrorResponse(HttpStatusCode.Conflict)); + + DistributedTransactionResponse response = await new DistributedWriteTransactionCore(contextMock.Object) + .CreateItem(Database, Container, new PartitionKey("pk"), new TestItem()) + .CommitTransactionAsync(CancellationToken.None); + + Assert.AreEqual(HttpStatusCode.Conflict, response.StatusCode); + Assert.IsFalse(response.IsSuccessStatusCode); + } + + // Helpers + + /// + /// Creates a transaction backed by a minimal context mock — suitable for + /// validation-only tests that do not invoke . + /// + private DistributedWriteTransaction NewTransaction() + { + return new DistributedWriteTransactionCore(this.BuildContextSetup().Object); + } + + /// + /// Builds a with the common dependencies + /// ( and + /// ) already set up. + /// Tests that intercept the outbound request add their own + /// setup on top. + /// + private Mock BuildContextSetup() + { + ContainerProperties containerProps = ContainerProperties.CreateWithResourceId("ccZ1ANCszwk="); + containerProps.PartitionKeyPath = "/pk"; + + Mock contextMock = new Mock(); + + contextMock + .Setup(c => c.SerializerCore) + .Returns(MockCosmosUtil.Serializer); + + contextMock + .Setup(c => c.GetCachedContainerPropertiesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(containerProps); + + return contextMock; + } + + private static ResponseMessage BuildSuccessResponse(int operationCount) + { + List results = new List(); + for (int i = 0; i < operationCount; i++) + { + results.Add($@"{{""index"":{i},""statusCode"":201}}"); + } + + string json = $@"{{""operationResponses"":[{string.Join(",", results)}]}}"; + return new ResponseMessage(HttpStatusCode.OK) + { + Content = new MemoryStream(Encoding.UTF8.GetBytes(json)) + }; + } + + private static ResponseMessage BuildErrorResponse(HttpStatusCode statusCode) + { + string json = $@"{{""operationResponses"":[{{""index"":0,""statusCode"":{(int)statusCode}}}]}}"; + return new ResponseMessage(statusCode) + { + Content = new MemoryStream(Encoding.UTF8.GetBytes(json)) + }; + } + + private sealed class TestItem + { + [System.Text.Json.Serialization.JsonPropertyName("id")] + public string Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("value")] + public string Value { get; set; } + + public TestItem() : this(Guid.NewGuid().ToString()) { } + + public TestItem(string id) + { + this.Id = id; + this.Value = "test-value"; + } + } + } +}