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