From c21155b52741e3834b6bd713f6cb0d085dab11a1 Mon Sep 17 00:00:00 2001 From: Meghana-Palaparthi Date: Fri, 6 Mar 2026 15:16:12 -0600 Subject: [PATCH 1/7] Add Distributed transaction tests --- .../DistributedTransactionCommitter.cs | 4 +- .../DistributedTransactionOperationResult.cs | 59 +- .../DistributedTransactionResponse.cs | 2 - .../DistributedTransactionE2ETests.cs | 384 ------------ .../DistributedTransactionTests.cs | 453 ++++++++++++++ .../DistributedTransactionResponseTests.cs | 583 ++++++++++++++++++ .../DistributedTransactionSerializerTests.cs | 330 ++++++++++ .../DistributedWriteTransactionTests.cs | 399 ++++++++++++ 8 files changed, 1802 insertions(+), 412 deletions(-) delete mode 100644 Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/DistributedTransaction/DistributedTransactionE2ETests.cs create mode 100644 Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/DistributedTransaction/DistributedTransactionTests.cs create mode 100644 Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionResponseTests.cs create mode 100644 Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionSerializerTests.cs create mode 100644 Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedWriteTransactionTests.cs 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..ba1bb89852 100644 --- a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs +++ b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs @@ -42,10 +42,9 @@ internal DistributedTransactionOperationResult(DistributedTransactionOperationRe /// /// Initializes a new instance of the class. - /// This protected constructor is intended for use by derived classes. /// [JsonConstructor] - protected DistributedTransactionOperationResult() + public DistributedTransactionOperationResult() { } @@ -60,7 +59,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 +90,24 @@ 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; } + [JsonInclude] + [JsonPropertyName("subStatusCode")] + public virtual uint SubStatusCodeValue + { + get => (uint)this.SubStatusCode; + internal set => this.SubStatusCode = (SubStatusCodes)value; + } + /// /// ActivityId related to the operation. /// @@ -127,6 +117,27 @@ internal string ResourceBodyBase64 [JsonIgnore] internal ITrace Trace { get; set; } + /// + /// Gets or sets the resource body as a JSON element. + /// Setting this property populates with the serialized bytes. + /// + [JsonInclude] + [JsonPropertyName("resourceBody")] + public virtual JsonElement? ResourceBody + { + get => null; + internal set + { + if (value.HasValue + && value.Value.ValueKind != JsonValueKind.Undefined + && value.Value.ValueKind != JsonValueKind.Null) + { + byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(value.Value); + this.ResourceStream = new MemoryStream(bytes, 0, bytes.Length, writable: false, publiclyVisible: true); + } + } + } + /// /// Creates a from a JSON element. /// @@ -134,7 +145,7 @@ internal string ResourceBodyBase64 /// The deserialized operation result. internal static DistributedTransactionOperationResult FromJson(JsonElement json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json.GetRawText()); } } } 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 b05f39288e..0000000000 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/DistributedTransaction/DistributedTransactionE2ETests.cs +++ /dev/null @@ -1,384 +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] - 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(); - } - - #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..bcb01561cb --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/DistributedTransaction/DistributedTransactionTests.cs @@ -0,0 +1,453 @@ +// ------------------------------------------------------------ +// 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] + [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(); + } + + // Helpers + + 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 handlergit + + /// + /// 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..23c5adf57d --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionResponseTests.cs @@ -0,0 +1,583 @@ +// ------------------------------------------------------------ +// 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("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); + } + + // 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..a83e9770aa --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionSerializerTests.cs @@ -0,0 +1,330 @@ +// ------------------------------------------------------------ +// 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."); + } + + // 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..c3164fbd99 --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedWriteTransactionTests.cs @@ -0,0 +1,399 @@ +// ------------------------------------------------------------ +// 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())); + } + + // 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"; + } + } + } +} From 343b6faf40e7f71f12095d93e1f5e9a5dbb332f0 Mon Sep 17 00:00:00 2001 From: Meghana-Palaparthi Date: Mon, 16 Mar 2026 11:53:15 -0500 Subject: [PATCH 2/7] fix build errors --- .../DistributedTransactionOperationResult.cs | 7 ++-- .../DistributedTransactionResponseTests.cs | 34 +++++++++---------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs index ba1bb89852..cbfc940dfb 100644 --- a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs +++ b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs @@ -100,6 +100,9 @@ public DistributedTransactionOperationResult() [JsonIgnore] internal virtual SubStatusCodes SubStatusCode { get; set; } + /// + /// Gets the sub-status code value as an unsigned integer. + /// [JsonInclude] [JsonPropertyName("subStatusCode")] public virtual uint SubStatusCodeValue @@ -123,10 +126,10 @@ public virtual uint SubStatusCodeValue /// [JsonInclude] [JsonPropertyName("resourceBody")] - public virtual JsonElement? ResourceBody + internal JsonElement? ResourceBody { get => null; - internal set + set { if (value.HasValue && value.Value.ValueKind != JsonValueKind.Undefined 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 index 23c5adf57d..e71d177d08 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionResponseTests.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionResponseTests.cs @@ -133,7 +133,7 @@ public async Task FromResponseMessage_CountMismatch_FewerResults_SuccessStatus_R // 2 operations submitted but server returns only 1 result DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 2); - string json = @"{""operationResponses"":[{""index"":0,""statuscode"":201}]}"; + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}"; ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( @@ -155,7 +155,7 @@ public async Task FromResponseMessage_CountMismatch_FewerResults_ErrorStatus_Pad // 3 operations submitted but server returns only 1 result DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 3); - string json = @"{""operationResponses"":[{""index"":0,""statuscode"":409}]}"; + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":409}]}"; ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.Conflict, json); DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( @@ -184,7 +184,7 @@ public async Task FromResponseMessage_MultiStatus_PromotesFirstNonDependencyFail 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}]}"; + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201},{""index"":1,""statusCode"":409}]}"; ResponseMessage responseMessage = BuildResponseMessage((HttpStatusCode)StatusCodes.MultiStatus, json); DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( @@ -208,7 +208,7 @@ public async Task FromResponseMessage_MultiStatus_SkipsSuccessesBeforeFirstFailu 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}]}"; + 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( @@ -231,7 +231,7 @@ public async Task FromResponseMessage_MultiStatus_AllFailedDependency_StatusRema 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}}}]}}"; + 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( @@ -255,7 +255,7 @@ public async Task FromResponseMessage_IdempotencyToken_MissingFromHeader_FallsBa DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); Guid requestToken = Guid.NewGuid(); - string json = @"{""operationResponses"":[{""index"":0,""statuscode"":201}]}"; + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}"; ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); // No IdempotencyToken header added @@ -278,7 +278,7 @@ public async Task FromResponseMessage_IdempotencyToken_InvalidGuidInHeader_Falls DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); Guid requestToken = Guid.NewGuid(); - string json = @"{""operationResponses"":[{""index"":0,""statuscode"":201}]}"; + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}"; ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); responseMessage.Headers.Add(HttpConstants.HttpHeaders.IdempotencyToken, "not-a-valid-guid"); @@ -302,7 +302,7 @@ public async Task Dispose_ReleasesResultResourceStreams() { DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); - string json = @"{""operationResponses"":[{""index"":0,""statuscode"":201,""resourcebody"":{""id"":""item1""}}]}"; + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201,""resourceBody"":{""id"":""item1""}}]}"; ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( @@ -326,7 +326,7 @@ public async Task Dispose_ReleasesResultResourceStreams() public async Task Dispose_SecondCall_DoesNotThrow() { DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); - string json = @"{""operationResponses"":[{""index"":0,""statuscode"":201}]}"; + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}"; ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( @@ -346,7 +346,7 @@ public async Task Dispose_SecondCall_DoesNotThrow() public async Task Indexer_AfterDispose_ThrowsObjectDisposedException() { DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); - string json = @"{""operationResponses"":[{""index"":0,""statuscode"":201}]}"; + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}"; ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( @@ -403,7 +403,7 @@ public async Task IsSuccessStatusCode_199_ReturnsFalse() public async Task Indexer_ValidIndex_ReturnsExpectedResult() { DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 2); - string json = @"{""operationResponses"":[{""index"":0,""statuscode"":201},{""index"":1,""statuscode"":200}]}"; + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201},{""index"":1,""statusCode"":200}]}"; ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( @@ -425,7 +425,7 @@ public async Task Indexer_ValidIndex_ReturnsExpectedResult() public async Task Indexer_NegativeIndex_ThrowsArgumentOutOfRangeException() { DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); - string json = @"{""operationResponses"":[{""index"":0,""statuscode"":201}]}"; + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}"; ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( @@ -444,7 +444,7 @@ public async Task Indexer_NegativeIndex_ThrowsArgumentOutOfRangeException() public async Task Indexer_IndexEqualsCount_ThrowsArgumentOutOfRangeException() { DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1); - string json = @"{""operationResponses"":[{""index"":0,""statuscode"":201}]}"; + string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}"; ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json); DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync( @@ -463,7 +463,7 @@ public async Task Indexer_IndexEqualsCount_ThrowsArgumentOutOfRangeException() 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}]}"; + 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( @@ -494,7 +494,7 @@ public async Task GetEnumerator_ReturnsAllResults_InOrder() 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}]}"; + 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( @@ -559,14 +559,14 @@ private static async Task BuildResponseWithStatu if (isError) { // Produce intentionally mismatched JSON so the padded-results path is exercised - json = $@"{{""operationResponses"":[{{""index"":0,""statuscode"":{(int)statusCode}}}]}}"; + 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}}}"); + results.Add($@"{{""index"":{i},""statusCode"":{(int)statusCode}}}"); } json = $@"{{""operationResponses"":[{string.Join(",", results)}]}}"; } From 99555a68ebd6eb58aa60da8d77ad3c7342735cb4 Mon Sep 17 00:00:00 2001 From: Meghana-Palaparthi Date: Mon, 16 Mar 2026 14:35:27 -0500 Subject: [PATCH 3/7] remove internal resource body and move the deserialization to FromJson --- .../DistributedTransactionOperationResult.cs | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs index cbfc940dfb..6c4138647a 100644 --- a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs +++ b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs @@ -121,34 +121,28 @@ public virtual uint SubStatusCodeValue internal ITrace Trace { get; set; } /// - /// Gets or sets the resource body as a JSON element. - /// Setting this property populates with the serialized bytes. + /// Creates a from a JSON element. /// - [JsonInclude] - [JsonPropertyName("resourceBody")] - internal JsonElement? ResourceBody + /// The JSON element containing the operation result. + /// The deserialized operation result. + internal static DistributedTransactionOperationResult FromJson(JsonElement json) { - get => null; - set + DistributedTransactionOperationResult result = JsonSerializer.Deserialize(json.GetRawText()); + + if (json.TryGetProperty("resourceBody", out JsonElement resourceBody) + && resourceBody.ValueKind != JsonValueKind.Undefined + && resourceBody.ValueKind != JsonValueKind.Null) { - if (value.HasValue - && value.Value.ValueKind != JsonValueKind.Undefined - && value.Value.ValueKind != JsonValueKind.Null) + if (resourceBody.ValueKind != JsonValueKind.Object) { - byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(value.Value); - this.ResourceStream = new MemoryStream(bytes, 0, bytes.Length, writable: false, publiclyVisible: true); + 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); } - } - /// - /// Creates a from a JSON element. - /// - /// The JSON element containing the operation result. - /// The deserialized operation result. - internal static DistributedTransactionOperationResult FromJson(JsonElement json) - { - return JsonSerializer.Deserialize(json.GetRawText()); + return result; } } } From b84dc1e44f1de2e640e5e3abea47b9931ebf3c5d Mon Sep 17 00:00:00 2001 From: Meghana-Palaparthi Date: Tue, 24 Mar 2026 15:57:39 -0500 Subject: [PATCH 4/7] [Internal] Tests: Fixes DistributedTransactionTests JSON property casing and adds DoNotParallelize Two bugs in DistributedTransactionTests.cs: 1. Mock JSON used all-lowercase property names ('statuscode', 'substatuscode', 'resourcebody') that do not match the model's JsonPropertyName attributes ('statusCode', 'subStatusCode') or the manual TryGetProperty lookup ('resourceBody'). System.Text.Json deserialization is case-sensitive, so StatusCode was always 0 causing all status-code assertions to fail. 2. Missing [DoNotParallelize] attribute. The TestInitialize calls TestInit() which runs DeleteAllDatabasesAsync; without [DoNotParallelize] concurrent test execution causes emulator resource contention (same fix as #5711 for the predecessor class DistributedTransactionE2ETests). Also removes stray 'git' text from an inline comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DistributedTransactionTests.cs | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) 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 index bcb01561cb..7623fe0e61 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/DistributedTransaction/DistributedTransactionTests.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/DistributedTransaction/DistributedTransactionTests.cs @@ -27,6 +27,7 @@ namespace Microsoft.Azure.Cosmos.SDK.EmulatorTests /// emulator to natively support distributed transactions. /// [TestClass] + [DoNotParallelize] [TestCategory("DistributedTransaction")] public class DistributedTransactionTests : BaseCosmosClientHelper { @@ -259,9 +260,9 @@ public async Task SuccessfulCreate_ResponseContainsResourceBody() string mockResponseJson = $@"{{ ""operationResponses"": [{{ ""index"": 0, - ""statuscode"": 201, + ""statusCode"": 201, ""etag"": ""\""test-etag\"""", - ""resourcebody"": {resourceBodyJson} + ""resourceBody"": {resourceBodyJson} }}] }}"; @@ -296,8 +297,8 @@ public async Task ConflictResponse_ReturnsFailureStatus() string mockErrorJson = @"{ ""operationResponses"": [{ ""index"": 0, - ""statuscode"": 409, - ""substatuscode"": 0 + ""statusCode"": 409, + ""subStatusCode"": 0 }] }"; @@ -326,8 +327,8 @@ public async Task NotFoundResponse_OnReplaceItem_ReturnsFailureStatus() string mockErrorJson = @"{ ""operationResponses"": [{ ""index"": 0, - ""statuscode"": 404, - ""substatuscode"": 0 + ""statusCode"": 404, + ""subStatusCode"": 0 }] }"; @@ -355,8 +356,8 @@ 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 } + { ""index"": 0, ""statusCode"": 201 }, + { ""index"": 1, ""statusCode"": 409, ""subStatusCode"": 0 } ] }"; @@ -405,13 +406,13 @@ 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}\""""}}"); + results.Add($@"{{""index"":{i},""statusCode"":201,""etag"":""\""etag-{i}\""""}}"); } return $@"{{""operationResponses"":[{string.Join(",", results)}]}}"; } - // Mock handlergit + // Mock handler /// /// Intercepts DTC commit requests (URLs ending in "/dtc"), captures the serialized From 82674e3e3c2998276e2a0dd28576166d216aac5c Mon Sep 17 00:00:00 2001 From: Meghana-Palaparthi Date: Wed, 25 Mar 2026 12:18:37 -0500 Subject: [PATCH 5/7] DTS: address PR #5686 feedback - Make [JsonConstructor] ctor internal (was public) -- result types should not be externally constructable; System.Text.Json honors [JsonConstructor] on non-public ctors via reflection on .NET 6+ - Use JsonSerializer.Deserialize(JsonElement) overload directly in FromJson instead of json.GetRawText() to avoid unnecessary string allocation - Wrap JsonSerializer.Deserialize in try-catch(JsonException) in FromJson so a malformed individual operation result returns InternalServerError instead of crashing the entire response parse - Fix statuscode -> statusCode casing in BuildSuccessResponse/BuildErrorResponse helpers in DistributedWriteTransactionTests and DistributedTransactionSerializerTests - Add SubStatusCode and RequestCharge deserialization tests to DistributedTransactionResponseTests Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DistributedTransactionOperationResult.cs | 21 ++++- .../DistributedTransactionResponseTests.cs | 92 +++++++++++++++++++ .../DistributedTransactionSerializerTests.cs | 2 +- .../DistributedWriteTransactionTests.cs | 4 +- 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs index 6c4138647a..f357627add 100644 --- a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs +++ b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs @@ -43,6 +43,12 @@ internal DistributedTransactionOperationResult(DistributedTransactionOperationRe /// /// Initializes a new instance of the class. /// + /// + /// 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] public DistributedTransactionOperationResult() { @@ -127,12 +133,25 @@ public virtual uint SubStatusCodeValue /// The deserialized operation result. internal static DistributedTransactionOperationResult FromJson(JsonElement json) { - DistributedTransactionOperationResult result = JsonSerializer.Deserialize(json.GetRawText()); + DistributedTransactionOperationResult result = null; + try + { + result = JsonSerializer.Deserialize(json); + } + catch (JsonException) + { + } + + if (result == null) + { + return new DistributedTransactionOperationResult(HttpStatusCode.InternalServerError); + } 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}'."); 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 index e71d177d08..a4428796c2 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionResponseTests.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionResponseTests.cs @@ -508,6 +508,98 @@ public async Task Count_ReturnsNumberOfParsedResults() 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 /// 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 index a83e9770aa..39b19ef701 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionSerializerTests.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionSerializerTests.cs @@ -302,7 +302,7 @@ private ResponseMessage BuildSuccessResponse(int operationCount) List results = new List(); for (int i = 0; i < operationCount; i++) { - results.Add($@"{{""index"":{i},""statuscode"":201}}"); + results.Add($@"{{""index"":{i},""statusCode"":201}}"); } string json = $@"{{""operationResponses"":[{string.Join(",", results)}]}}"; 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 index c3164fbd99..3bd3c259e8 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedWriteTransactionTests.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedWriteTransactionTests.cs @@ -360,7 +360,7 @@ private static ResponseMessage BuildSuccessResponse(int operationCount) List results = new List(); for (int i = 0; i < operationCount; i++) { - results.Add($@"{{""index"":{i},""statuscode"":201}}"); + results.Add($@"{{""index"":{i},""statusCode"":201}}"); } string json = $@"{{""operationResponses"":[{string.Join(",", results)}]}}"; @@ -372,7 +372,7 @@ private static ResponseMessage BuildSuccessResponse(int operationCount) private static ResponseMessage BuildErrorResponse(HttpStatusCode statusCode) { - string json = $@"{{""operationResponses"":[{{""index"":0,""statuscode"":{(int)statusCode}}}]}}"; + string json = $@"{{""operationResponses"":[{{""index"":0,""statusCode"":{(int)statusCode}}}]}}"; return new ResponseMessage(statusCode) { Content = new MemoryStream(Encoding.UTF8.GetBytes(json)) From b5914dd90d6ea2b16131f2e4e0b40fc7281aef90 Mon Sep 17 00:00:00 2001 From: Meghana-Palaparthi Date: Wed, 1 Apr 2026 16:17:39 -0500 Subject: [PATCH 6/7] Address feedback. Make deserializer case-insensitive --- .../DistributedTransactionOperationResult.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs index f357627add..c5826fa98a 100644 --- a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs +++ b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs @@ -126,6 +126,11 @@ public virtual uint SubStatusCodeValue [JsonIgnore] internal ITrace Trace { get; set; } + private static readonly JsonSerializerOptions CaseInsensitiveOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }; + /// /// Creates a from a JSON element. /// @@ -136,7 +141,7 @@ internal static DistributedTransactionOperationResult FromJson(JsonElement json) DistributedTransactionOperationResult result = null; try { - result = JsonSerializer.Deserialize(json); + result = JsonSerializer.Deserialize(json, DistributedTransactionOperationResult.CaseInsensitiveOptions); } catch (JsonException) { From d05b1ff1142c3868e3860c217c540d9ea68f97ef Mon Sep 17 00:00:00 2001 From: Meghana-Palaparthi Date: Thu, 2 Apr 2026 17:50:05 -0500 Subject: [PATCH 7/7] Address feedback: remove error handling --- .../DistributedTransactionOperationResult.cs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs index c5826fa98a..de63261e1e 100644 --- a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs +++ b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs @@ -138,19 +138,7 @@ public virtual uint SubStatusCodeValue /// The deserialized operation result. internal static DistributedTransactionOperationResult FromJson(JsonElement json) { - DistributedTransactionOperationResult result = null; - try - { - result = JsonSerializer.Deserialize(json, DistributedTransactionOperationResult.CaseInsensitiveOptions); - } - catch (JsonException) - { - } - - if (result == null) - { - return new DistributedTransactionOperationResult(HttpStatusCode.InternalServerError); - } + DistributedTransactionOperationResult result = JsonSerializer.Deserialize(json, DistributedTransactionOperationResult.CaseInsensitiveOptions); if (json.TryGetProperty("resourceBody", out JsonElement resourceBody) && resourceBody.ValueKind != JsonValueKind.Undefined