diff --git a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs
index d8d51cc8a1..dc5b06e131 100644
--- a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs
+++ b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionOperationResult.cs
@@ -8,7 +8,6 @@ namespace Microsoft.Azure.Cosmos
using System.IO;
using System.Net;
using System.Text.Json;
- using System.Text.Json.Serialization;
using Microsoft.Azure.Cosmos.Core.Trace;
using Microsoft.Azure.Cosmos.Tracing;
using Microsoft.Azure.Documents;
@@ -46,59 +45,31 @@ internal DistributedTransactionOperationResult(DistributedTransactionOperationRe
///
/// Initializes a new instance of the class.
///
- ///
- /// Must be public for System.Text.Json reflection-based deserialization.
- /// System.Text.Json 6.x only scans BindingFlags.Public constructors when resolving
- /// ; non-public constructors are not found.
- /// Support for non-public constructors was added in System.Text.Json 7.0.
- ///
- [JsonConstructor]
- public DistributedTransactionOperationResult()
+ internal DistributedTransactionOperationResult()
{
}
///
/// Gets the index of this operation within the distributed transaction.
///
- [JsonInclude]
- [JsonPropertyName("index")]
public virtual int Index { get; internal set; }
///
/// Gets the HTTP status code returned by the operation.
///
- [JsonInclude]
- [JsonPropertyName("statusCode")]
public virtual HttpStatusCode StatusCode { get; internal set; }
///
/// Gets a value indicating whether the HTTP status code returned by the operation indicates success.
///
- [JsonIgnore]
public virtual bool IsSuccessStatusCode => (int)this.StatusCode >= 200 && (int)this.StatusCode <= 299;
///
/// Gets the entity tag (ETag) associated with the operation result.
/// The ETag is used for concurrency control and represents the version of the resource.
///
- [JsonInclude]
- [JsonPropertyName("etag")]
public virtual string ETag { get; internal set; }
- ///
- /// Gets the session token associated with the operation result.
- ///
- [JsonInclude]
- [JsonPropertyName("sessionToken")]
- public virtual string SessionToken { get; internal set; }
-
- ///
- /// Gets the raw partition key range ID emitted by the server.
- ///
- [JsonInclude]
- [JsonPropertyName("partitionKeyRangeId")]
- public virtual string PartitionKeyRangeId { get; internal set; }
-
///
/// Gets the resource stream associated with the operation result.
/// The stream contains the raw response payload returned by the operation.
@@ -109,47 +80,28 @@ public DistributedTransactionOperationResult()
/// Do not dispose it directly. To deserialize to a typed object, use
/// .
///
- [JsonIgnore]
public virtual Stream ResourceStream { get; internal set; }
///
/// Gets the number of request units consumed by this operation.
///
- [JsonInclude]
- [JsonPropertyName("requestCharge")]
public virtual double RequestCharge { get; internal set; }
- [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;
- }
+ internal virtual string SessionToken { get; set; }
+
+ internal virtual string PartitionKeyRangeId { get; set; }
///
/// ActivityId related to the operation.
///
- [JsonIgnore]
internal virtual string ActivityId { get; set; }
- [JsonIgnore]
internal ITrace Trace { get; set; }
- [JsonIgnore]
internal CosmosSerializerCore SerializerCore { get; set; }
- private static readonly JsonSerializerOptions CaseInsensitiveOptions = new JsonSerializerOptions
- {
- PropertyNameCaseInsensitive = true,
- };
-
///
/// Returns a fresh that exposes the same bytes as
/// without affecting the source stream's position or lifetime.
@@ -197,10 +149,49 @@ internal static Stream CreateSnapshot(Stream source)
/// The deserialized operation result with a canonical session token.
internal static DistributedTransactionOperationResult FromJson(JsonElement json)
{
- DistributedTransactionOperationResult result = JsonSerializer.Deserialize(json, DistributedTransactionOperationResult.CaseInsensitiveOptions)
- ?? throw new JsonException($"Failed to deserialize DTC operation result: Deserialize returned null. JSON element kind: '{json.ValueKind}'.");
+ if (json.ValueKind != JsonValueKind.Object)
+ {
+ throw new JsonException($"DTC operation result must be a JSON object, but was '{json.ValueKind}'.");
+ }
- if (json.TryGetProperty(DistributedTransactionSerializer.ResourceBody, out JsonElement resourceBody)
+ DistributedTransactionOperationResult result = new DistributedTransactionOperationResult();
+
+ if (TryGetInt32Property(json, DistributedTransactionSerializer.Index, out int index))
+ {
+ result.Index = index;
+ }
+
+ if (TryGetInt32Property(json, DistributedTransactionSerializer.StatusCode, out int statusCode))
+ {
+ result.StatusCode = (HttpStatusCode)statusCode;
+ }
+
+ if (TryGetUInt32Property(json, DistributedTransactionSerializer.SubStatusCode, out uint subStatus))
+ {
+ result.SubStatusCode = (SubStatusCodes)subStatus;
+ }
+
+ if (TryGetProperty(json, DistributedTransactionSerializer.ResponseETag, out JsonElement etagEl) && etagEl.ValueKind == JsonValueKind.String)
+ {
+ result.ETag = etagEl.GetString();
+ }
+
+ if (TryGetProperty(json, DistributedTransactionSerializer.SessionToken, out JsonElement sessionTokenEl) && sessionTokenEl.ValueKind == JsonValueKind.String)
+ {
+ result.SessionToken = sessionTokenEl.GetString();
+ }
+
+ if (TryGetProperty(json, DistributedTransactionSerializer.PartitionKeyRangeId, out JsonElement pkRangeIdEl) && pkRangeIdEl.ValueKind == JsonValueKind.String)
+ {
+ result.PartitionKeyRangeId = pkRangeIdEl.GetString();
+ }
+
+ if (TryGetDoubleProperty(json, DistributedTransactionSerializer.RequestCharge, out double requestCharge))
+ {
+ result.RequestCharge = requestCharge;
+ }
+
+ if (TryGetProperty(json, DistributedTransactionSerializer.ResourceBody, out JsonElement resourceBody)
&& resourceBody.ValueKind != JsonValueKind.Undefined
&& resourceBody.ValueKind != JsonValueKind.Null)
{
@@ -242,6 +233,90 @@ internal static DistributedTransactionOperationResult FromJson(JsonElement json)
return result;
}
+
+ internal static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value)
+ {
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ value = default;
+ return false;
+ }
+
+ foreach (JsonProperty prop in element.EnumerateObject())
+ {
+ if (string.Equals(prop.Name, propertyName, StringComparison.OrdinalIgnoreCase))
+ {
+ value = prop.Value;
+ return true;
+ }
+ }
+
+ value = default;
+ return false;
+ }
+
+ private static bool TryGetInt32Property(JsonElement element, string propertyName, out int value)
+ {
+ if (TryGetProperty(element, propertyName, out JsonElement propertyElement))
+ {
+ if (propertyElement.ValueKind != JsonValueKind.Number)
+ {
+ throw new JsonException($"'{propertyName}' must be a JSON number, but was '{propertyElement.ValueKind}'.");
+ }
+
+ if (!propertyElement.TryGetInt32(out value))
+ {
+ throw new JsonException($"'{propertyName}' must be a 32-bit integer JSON number.");
+ }
+
+ return true;
+ }
+
+ value = default;
+ return false;
+ }
+
+ private static bool TryGetUInt32Property(JsonElement element, string propertyName, out uint value)
+ {
+ if (TryGetProperty(element, propertyName, out JsonElement propertyElement))
+ {
+ if (propertyElement.ValueKind != JsonValueKind.Number)
+ {
+ throw new JsonException($"'{propertyName}' must be a JSON number, but was '{propertyElement.ValueKind}'.");
+ }
+
+ if (!propertyElement.TryGetUInt32(out value))
+ {
+ throw new JsonException($"'{propertyName}' must be a 32-bit unsigned integer JSON number.");
+ }
+
+ return true;
+ }
+
+ value = default;
+ return false;
+ }
+
+ private static bool TryGetDoubleProperty(JsonElement element, string propertyName, out double value)
+ {
+ if (TryGetProperty(element, propertyName, out JsonElement propertyElement))
+ {
+ if (propertyElement.ValueKind != JsonValueKind.Number)
+ {
+ throw new JsonException($"'{propertyName}' must be a JSON number, but was '{propertyElement.ValueKind}'.");
+ }
+
+ if (!propertyElement.TryGetDouble(out value))
+ {
+ throw new JsonException($"'{propertyName}' must be a double-precision JSON number.");
+ }
+
+ return true;
+ }
+
+ value = default;
+ return false;
+ }
}
///
diff --git a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionResponse.cs b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionResponse.cs
index 776c23ceb0..21d55e57ad 100644
--- a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionResponse.cs
+++ b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionResponse.cs
@@ -36,7 +36,6 @@ private DistributedTransactionResponse(
Headers headers,
IReadOnlyList operations,
CosmosSerializerCore serializer,
- ITrace trace,
Guid idempotencyToken,
bool isRetriable = false)
{
@@ -46,7 +45,6 @@ private DistributedTransactionResponse(
this.ErrorMessage = errorMessage;
this.Operations = operations;
this.SerializerCore = serializer;
- this.Trace = trace;
this.IdempotencyToken = idempotencyToken;
this.IsRetriable = isRetriable;
}
@@ -145,7 +143,7 @@ public virtual DistributedTransactionOperationResult GetOperationResultAtInde
public virtual bool IsSuccessStatusCode => (int)this.StatusCode >= 200 && (int)this.StatusCode <= 299;
///
- /// Gets the error message associated with the distributed transaction response, if any.
+ /// Gets the error message associated with the distributed transaction response.
///
public virtual string ErrorMessage { get; }
@@ -183,8 +181,6 @@ public virtual DistributedTransactionOperationResult GetOperationResultAtInde
internal IReadOnlyList Operations { get; }
- internal ITrace Trace { get; }
-
///
/// Returns an enumerator that iterates through the operation results.
///
@@ -223,7 +219,7 @@ internal static async Task FromResponseMessageAs
ITrace trace,
CancellationToken cancellationToken)
{
- using (ITrace createResponseTrace = trace.StartChild("Create Distributed Transaction Response", TraceComponent.Batch, TraceLevel.Info))
+ using (trace.StartChild("Create Distributed Transaction Response", TraceComponent.Batch, TraceLevel.Info))
{
cancellationToken.ThrowIfCancellationRequested();
@@ -254,7 +250,6 @@ internal static async Task FromResponseMessageAs
serverRequest,
serializer,
idempotencyToken,
- createResponseTrace,
cancellationToken);
}
@@ -266,7 +261,6 @@ internal static async Task FromResponseMessageAs
responseMessage.Headers,
serverRequest.Operations,
serializer,
- createResponseTrace,
idempotencyToken);
// Validate results count matches operations count
@@ -278,7 +272,6 @@ internal static async Task FromResponseMessageAs
if (responseMessage.IsSuccessStatusCode)
{
- string preservedDiagnosticString = response.DiagnosticString;
SubStatusCodes wireSubStatusCode = responseMessage.Headers.SubStatusCode;
response.Dispose();
@@ -289,14 +282,10 @@ internal static async Task FromResponseMessageAs
responseMessage.Headers,
serverRequest.Operations,
serializer,
- createResponseTrace,
- idempotencyToken)
- {
- DiagnosticString = preservedDiagnosticString,
- };
+ idempotencyToken);
}
- response.CreateAndPopulateResults(serverRequest.Operations, createResponseTrace);
+ response.CreateAndPopulateResults(serverRequest.Operations);
}
return response;
@@ -357,7 +346,6 @@ private static async Task PopulateFromJsonConten
DistributedTransactionServerRequest serverRequest,
CosmosSerializerCore serializer,
Guid idempotencyToken,
- ITrace trace,
CancellationToken cancellationToken)
{
List results = new List();
@@ -374,6 +362,12 @@ private static async Task PopulateFromJsonConten
DefaultTrace.TraceWarning(
"DistributedTransactionResponse: failed to parse response body: {0}",
jsonEx.Message);
+
+ if (responseMessage.IsSuccessStatusCode)
+ {
+ return CreateDeserializationFailureResponse(responseMessage, serverRequest, serializer, idempotencyToken);
+ }
+
return null;
}
@@ -381,20 +375,20 @@ private static async Task PopulateFromJsonConten
{
JsonElement root = responseJson.RootElement;
- if (root.TryGetProperty("isRetriable", out JsonElement isRetriableElement) &&
+ if (DistributedTransactionOperationResult.TryGetProperty(root, DistributedTransactionSerializer.IsRetriable, out JsonElement isRetriableElement) &&
isRetriableElement.ValueKind == JsonValueKind.True)
{
isRetriable = true;
}
- if (root.TryGetProperty(DistributedTransactionSerializer.DiagnosticString, out JsonElement diagnosticStringElement) &&
+ if (DistributedTransactionOperationResult.TryGetProperty(root, DistributedTransactionSerializer.DiagnosticString, out JsonElement diagnosticStringElement) &&
diagnosticStringElement.ValueKind == JsonValueKind.String)
{
diagnosticString = diagnosticStringElement.GetString();
}
// Parse operation results from "operationResponses" array.
- if (root.TryGetProperty("operationResponses", out JsonElement operationResponses) &&
+ if (DistributedTransactionOperationResult.TryGetProperty(root, DistributedTransactionSerializer.OperationResponses, out JsonElement operationResponses) &&
operationResponses.ValueKind == JsonValueKind.Array)
{
try
@@ -404,9 +398,6 @@ private static async Task PopulateFromJsonConten
cancellationToken.ThrowIfCancellationRequested();
DistributedTransactionOperationResult operationResult = DistributedTransactionOperationResult.FromJson(operationElement);
- operationResult.Trace = trace;
- operationResult.ActivityId = responseMessage.Headers.ActivityId;
- operationResult.SerializerCore = serializer;
results.Add(operationResult);
}
}
@@ -415,8 +406,21 @@ private static async Task PopulateFromJsonConten
DefaultTrace.TraceWarning(
"DistributedTransactionResponse: per-operation parse failed; forcing isRetriable=false. {0}",
jsonEx.Message);
+
+ // Dispose any resource streams allocated for the partially-parsed operations
+ // before discarding them.
+ foreach (DistributedTransactionOperationResult partial in results)
+ {
+ partial.ResourceStream?.Dispose();
+ }
+
results.Clear();
isRetriable = false;
+
+ if (responseMessage.IsSuccessStatusCode)
+ {
+ return CreateDeserializationFailureResponse(responseMessage, serverRequest, serializer, idempotencyToken);
+ }
}
}
}
@@ -458,7 +462,6 @@ private static async Task PopulateFromJsonConten
responseMessage.Headers,
serverRequest.Operations,
serializer,
- trace,
idempotencyToken,
isRetriable)
{
@@ -468,8 +471,7 @@ private static async Task PopulateFromJsonConten
}
private void CreateAndPopulateResults(
- IReadOnlyList operations,
- ITrace trace)
+ IReadOnlyList operations)
{
this.results = new List(operations.Count);
@@ -478,11 +480,31 @@ private void CreateAndPopulateResults(
this.results.Add(new DistributedTransactionOperationResult(this.StatusCode)
{
SubStatusCode = this.SubStatusCode,
- ActivityId = this.ActivityId,
- Trace = trace,
- SerializerCore = this.SerializerCore,
});
}
}
+
+ ///
+ /// Builds an InternalServerError response indicating the server replied with success but
+ /// the SDK could not deserialize the response payload. Mirrors TransactionalBatch behavior.
+ ///
+ private static DistributedTransactionResponse CreateDeserializationFailureResponse(
+ ResponseMessage responseMessage,
+ DistributedTransactionServerRequest serverRequest,
+ CosmosSerializerCore serializer,
+ Guid idempotencyToken)
+ {
+ DistributedTransactionResponse failedResponse = new DistributedTransactionResponse(
+ HttpStatusCode.InternalServerError,
+ SubStatusCodes.Unknown,
+ ClientResources.ServerResponseDeserializationFailure,
+ responseMessage.Headers,
+ serverRequest.Operations,
+ serializer,
+ idempotencyToken);
+
+ failedResponse.CreateAndPopulateResults(serverRequest.Operations);
+ return failedResponse;
+ }
}
}
diff --git a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionSerializer.cs b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionSerializer.cs
index 5987c94a8f..63187b35c9 100644
--- a/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionSerializer.cs
+++ b/Microsoft.Azure.Cosmos/src/DistributedTransaction/DistributedTransactionSerializer.cs
@@ -24,10 +24,16 @@ internal static class DistributedTransactionSerializer
internal const string DatabaseResourceId = "databaseResourceId";
internal const string PartitionKey = "partitionKey";
internal const string Index = "index";
+ internal const string StatusCode = "statusCode";
+ internal const string SubStatusCode = "subStatusCode";
internal const string ResourceBody = "resourceBody";
+ internal const string RequestCharge = "requestCharge";
+ internal const string IsRetriable = "isRetriable";
+ internal const string OperationResponses = "operationResponses";
internal const string SessionToken = "sessionToken";
internal const string PartitionKeyRangeId = "partitionKeyRangeId";
internal const string ETag = "ifMatch";
+ internal const string ResponseETag = "Etag";
internal const string OperationType = "operationType";
internal const string ResourceType = "resourceType";
internal const string DiagnosticString = "diagnosticString";
diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionResponseTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionResponseTests.cs
index acd6c5dd75..66aa6bbce9 100644
--- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionResponseTests.cs
+++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DistributedTransaction/DistributedTransactionResponseTests.cs
@@ -100,7 +100,7 @@ public async Task FromResponseMessage_PreconditionFailed_ReturnsFailureStatus()
}
[TestMethod]
- [Description("When the response body contains malformed JSON and the HTTP status is success, the SDK must return 500.")]
+ [Description("When the response body contains malformed JSON and the HTTP status is success, the SDK must return 500 with a deserialization-failure error message.")]
public async Task FromResponseMessage_MalformedJson_SuccessStatus_ReturnsInternalServerError()
{
DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1);
@@ -116,6 +116,7 @@ public async Task FromResponseMessage_MalformedJson_SuccessStatus_ReturnsInterna
Assert.AreEqual(HttpStatusCode.InternalServerError, response.StatusCode);
Assert.IsFalse(response.IsSuccessStatusCode);
+ Assert.AreEqual(ClientResources.ServerResponseDeserializationFailure, response.ErrorMessage);
}
[TestMethod]
@@ -142,6 +143,51 @@ public async Task FromResponseMessage_MalformedJson_ErrorStatus_PopulatesResults
}
}
+ [DataTestMethod]
+ [Description("When per-operation fields have wrong types or non-object entries, parsing fails and a success response is converted to InternalServerError with a deserialization-failure message.")]
+ [DataRow(@"{""operationResponses"":[{""index"":0,""statusCode"":""abc""}]}", DisplayName = "statusCode wrong type")]
+ [DataRow(@"{""operationResponses"":[{""index"":0,""statusCode"":449,""subStatusCode"":""abc""}]}", DisplayName = "subStatusCode wrong type")]
+ [DataRow(@"{""operationResponses"":[{""index"":0,""statusCode"":201,""requestCharge"":""abc""}]}", DisplayName = "requestCharge wrong type")]
+ [DataRow(@"{""operationResponses"":[{""index"":""abc"",""statusCode"":201}]}", DisplayName = "index wrong type")]
+ [DataRow(@"{""operationResponses"":[null]}", DisplayName = "non-object element (null)")]
+ public async Task FromResponseMessage_OperationResult_InvalidElement_SuccessStatus_ReturnsInternalServerError(string json)
+ {
+ DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1);
+ ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json);
+
+ DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync(
+ responseMessage,
+ serverRequest,
+ MockCosmosUtil.Serializer,
+ NoOpTrace.Singleton,
+ CancellationToken.None);
+
+ Assert.AreEqual(HttpStatusCode.InternalServerError, response.StatusCode);
+ Assert.IsFalse(response.IsSuccessStatusCode);
+ Assert.AreEqual(ClientResources.ServerResponseDeserializationFailure, response.ErrorMessage);
+ }
+
+ [TestMethod]
+ [Description("Top-level lookups are case-insensitive; PascalCase 'OperationResponses' is accepted.")]
+ public async Task FromResponseMessage_OperationResponses_PascalCaseKey_SuccessStatus_ParsesSuccessfully()
+ {
+ 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,
+ NoOpTrace.Singleton,
+ CancellationToken.None);
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ Assert.IsTrue(response.IsSuccessStatusCode);
+ Assert.AreEqual(1, response.Count);
+ Assert.AreEqual(HttpStatusCode.Created, response[0].StatusCode);
+ }
+
// Count mismatch
[TestMethod]
@@ -284,36 +330,21 @@ public async Task FromResponseMessage_MultiStatus_AllFailedDependency_StatusRema
// 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()
+ [DataTestMethod]
+ [Description("When the IdempotencyToken response header is absent or unparseable, the SDK falls back to the request token.")]
+ [DataRow(null, DisplayName = "Header absent")]
+ [DataRow("not-a-valid-guid", DisplayName = "Invalid GUID in header")]
+ public async Task FromResponseMessage_IdempotencyToken_FallsBackToRequestToken(string headerValue)
{
DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1);
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,
- NoOpTrace.Singleton,
- CancellationToken.None);
-
- Assert.AreEqual(serverRequest.IdempotencyToken, 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);
- string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}";
- ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json);
- responseMessage.Headers.Add(HttpConstants.HttpHeaders.IdempotencyToken, "not-a-valid-guid");
+ if (headerValue != null)
+ {
+ responseMessage.Headers.Add(HttpConstants.HttpHeaders.IdempotencyToken, headerValue);
+ }
DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync(
responseMessage,
@@ -323,34 +354,11 @@ public async Task FromResponseMessage_IdempotencyToken_InvalidGuidInHeader_Falls
CancellationToken.None);
Assert.AreEqual(serverRequest.IdempotencyToken, response.IdempotencyToken,
- "An unparseable header value must fall back to the request token.");
+ "The request token must be used when the response header is absent or unparseable.");
}
// 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,
- NoOpTrace.Singleton,
- CancellationToken.None);
-
- Assert.IsNotNull(response[0].ResourceStream, "ResourceStream should be populated from resourcebody before Dispose.");
-
- response.Dispose();
-
- // Indexer-after-dispose behavior is covered by Indexer_AfterDispose_ThrowsObjectDisposedException.
- }
-
[TestMethod]
[Description("Calling Dispose() a second time must be a safe no-op.")]
public async Task Dispose_SecondCall_DoesNotThrow()
@@ -428,27 +436,11 @@ public async Task Indexer_ValidIndex_ReturnsExpectedResult()
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,
- NoOpTrace.Singleton,
- CancellationToken.None);
-
- Assert.ThrowsException(() => _ = response[-1]);
- }
-
- [TestMethod]
- [Description("Accessing index equal to Count must throw ArgumentOutOfRangeException.")]
- public async Task Indexer_IndexEqualsCount_ThrowsArgumentOutOfRangeException()
+ [DataTestMethod]
+ [Description("Accessing an out-of-range index must throw ArgumentOutOfRangeException.")]
+ [DataRow(-1, DisplayName = "Negative index")]
+ [DataRow(1, DisplayName = "Index equals count")]
+ public async Task Indexer_OutOfRange_ThrowsArgumentOutOfRangeException(int index)
{
DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1);
string json = @"{""operationResponses"":[{""index"":0,""statusCode"":201}]}";
@@ -461,7 +453,7 @@ public async Task Indexer_IndexEqualsCount_ThrowsArgumentOutOfRangeException()
NoOpTrace.Singleton,
CancellationToken.None);
- Assert.ThrowsException(() => _ = response[response.Count]);
+ Assert.ThrowsException(() => _ = response[index]);
}
[TestMethod]
@@ -577,6 +569,25 @@ public async Task FromResponseMessage_OperationResult_ETag_DeserializesCorrectly
"ETag must equal the value from the JSON 'etag' field.");
}
+ [TestMethod]
+ [Description("resourceBody lookup is case-insensitive; PascalCase 'ResourceBody' populates ResourceStream.")]
+ public async Task FromResponseMessage_OperationResult_ResourceBody_PascalCaseKey_Deserializes()
+ {
+ 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,
+ NoOpTrace.Singleton,
+ CancellationToken.None);
+
+ Assert.IsNotNull(response[0].ResourceStream, "ResourceStream should be populated because property name lookup is case-insensitive.");
+ }
+
[TestMethod]
[Description("SessionToken is assembled as {pkRangeId}:{lsn} from the separate 'sessionToken' (LSN-only) and 'partitionKeyRangeId' JSON fields.")]
public async Task FromResponseMessage_OperationResult_SessionToken_DeserializesCorrectly()
@@ -745,6 +756,7 @@ public async Task FromResponseMessage_OperationResult_SessionToken_NullWhenSessi
[DataRow(@"{""isRetriable"":false,""operationResponses"":[{""index"":0,""statusCode"":503}]}", false, DisplayName = "JSON boolean false → IsRetriable=false")]
[DataRow(@"{""operationResponses"":[{""index"":0,""statusCode"":503}]}", false, DisplayName = "isRetriable absent → IsRetriable=false")]
[DataRow(@"{""isRetriable"":""true"",""operationResponses"":[{""index"":0,""statusCode"":503}]}", false, DisplayName = "string 'true' (not a JSON boolean) → IsRetriable=false")]
+ [DataRow(@"{""IsRetriable"":true,""operationResponses"":[{""index"":0,""statusCode"":503}]}", true, DisplayName = "PascalCase 'IsRetriable' is accepted")]
public async Task FromResponseMessage_IsRetriable_Parsing(string json, bool expected)
{
DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1);
@@ -762,14 +774,16 @@ public async Task FromResponseMessage_IsRetriable_Parsing(string json, bool expe
// DiagnosticString parsing
- [TestMethod]
- [Description("DiagnosticString deserializes from the 'diagnosticString' JSON property.")]
- public async Task FromResponseMessage_DiagnosticString_DeserializesCorrectly()
+ [DataTestMethod]
+ [Description("DiagnosticString deserializes from the top-level JSON property case-insensitively.")]
+ [DataRow("diagnosticString", DisplayName = "camelCase key")]
+ [DataRow("DiagnosticString", DisplayName = "PascalCase key")]
+ public async Task FromResponseMessage_DiagnosticString_DeserializesCorrectly(string diagnosticStringPropertyName)
{
const string expectedDiagnosticString = "TransactionCommitted";
DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1);
- string json = $@"{{""diagnosticString"":""{expectedDiagnosticString}"",""operationResponses"":[{{""index"":0,""statusCode"":200}}]}}";
+ string json = $@"{{""{diagnosticStringPropertyName}"":""{expectedDiagnosticString}"",""operationResponses"":[{{""index"":0,""statusCode"":200}}]}}";
ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json);
DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync(
@@ -1194,31 +1208,6 @@ public async Task GetOperationResultAtIndex_CalledTwice_BothCallsSucceedAndStrea
StringAssert.Contains(raw, "\"id\":\"dup\"", "Underlying ResourceStream must remain readable after GetOperationResultAtIndex.");
}
- [TestMethod]
- [Description("Reading response[index].ResourceStream directly after GetOperationResultAtIndex must still return the original bytes.")]
- public async Task GetOperationResultAtIndex_FollowedByDirectStreamRead_StreamIsIntact()
- {
- DistributedTransactionServerRequest serverRequest = await BuildServerRequestAsync(operationCount: 1);
- string json = @"{""operationResponses"":[{""index"":0,""statusCode"":200,""resourceBody"":{""id"":""x"",""value"":99}}]}";
- ResponseMessage responseMessage = BuildResponseMessage(HttpStatusCode.OK, json);
-
- DistributedTransactionResponse response = await DistributedTransactionResponse.FromResponseMessageAsync(
- responseMessage,
- serverRequest,
- MockCosmosUtil.Serializer,
- NoOpTrace.Singleton,
- CancellationToken.None);
-
- _ = response.GetOperationResultAtIndex(0);
-
- Stream stream = response[0].ResourceStream;
- Assert.IsNotNull(stream);
- stream.Position = 0;
- using StreamReader reader = new StreamReader(stream);
- string raw = reader.ReadToEnd();
- Assert.AreEqual(@"{""id"":""x"",""value"":99}", raw);
- }
-
[TestMethod]
[Description("Calling Dispose() after GetOperationResultAtIndex must still successfully release the ResourceStream and must be safe to call multiple times.")]
public async Task GetOperationResultAtIndex_FollowedByDispose_DoesNotThrow()