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()