diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs index f259e0d..c5c05cc 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs @@ -35,6 +35,13 @@ namespace CosmosDB.InMemoryEmulator; /// internal class InMemoryContainer : Container, IContainerTestSetup { + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/ + // Error message constants matching real Cosmos DB REST API response bodies. + private const string MessageEntityDoesNotExist = "Entity with the specified id does not exist in the system."; + private const string MessageEntityAlreadyExists = "Entity with the specified id already exists in the system."; + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/replace-a-document (412 response) + private const string MessagePreconditionFailed = "One of the specified pre-condition is not met"; + private static readonly JsonSerializerSettings JsonSettings = new() { TypeNameHandling = TypeNameHandling.None, @@ -717,7 +724,7 @@ public override async Task> CreateItemAsync( ValidateUniqueKeys(jObj, pk); if (!_items.TryAdd(key, json)) { - throw new InMemoryCosmosException($"Entity with the specified id already exists in the system. id = {itemId}", + throw new InMemoryCosmosException($"{MessageEntityAlreadyExists} id = {itemId}", HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); } } @@ -726,7 +733,7 @@ public override async Task> CreateItemAsync( { if (!_items.TryAdd(key, json)) { - throw new InMemoryCosmosException($"Entity with the specified id already exists in the system. id = {itemId}", + throw new InMemoryCosmosException($"{MessageEntityAlreadyExists} id = {itemId}", HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); } } @@ -786,7 +793,7 @@ public override Task> ReadItemAsync( if (!_items.TryGetValue(key, out var json) || IsExpired(key)) { EvictIfExpired(key); - throw new InMemoryCosmosException($"Entity with the specified id does not exist in the system. id = {id}", + throw new InMemoryCosmosException($"{MessageEntityDoesNotExist} id = {id}", HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); } @@ -824,7 +831,7 @@ public override async Task> UpsertItemAsync( if (requestOptions?.IfMatchEtag is not null && !_items.ContainsKey(key)) { - throw new InMemoryCosmosException($"Entity with the specified id does not exist. id = {itemId}", + throw new InMemoryCosmosException($"{MessageEntityDoesNotExist} id = {itemId}", HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); } @@ -934,7 +941,7 @@ public override async Task> ReplaceItemAsync( if (!_items.ContainsKey(key) || IsExpired(key)) { EvictIfExpired(key); - throw new InMemoryCosmosException($"Entity with the specified id does not exist. id = {id}", + throw new InMemoryCosmosException($"{MessageEntityDoesNotExist} id = {id}", HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); } @@ -1018,7 +1025,7 @@ public override async Task> DeleteItemAsync( if (!_items.TryGetValue(key, out var existingJson) || IsExpired(key)) { EvictIfExpired(key); - throw new InMemoryCosmosException($"Entity with the specified id does not exist. id = {id}", + throw new InMemoryCosmosException($"{MessageEntityDoesNotExist} id = {id}", HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); } @@ -1099,7 +1106,7 @@ private ItemResponse PatchItemCore( if (!_items.TryGetValue(key, out var existingJson) || IsExpired(key)) { EvictIfExpired(key); - throw new InMemoryCosmosException($"Entity with the specified id does not exist. id = {id}", + throw new InMemoryCosmosException($"{MessageEntityDoesNotExist} id = {id}", HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); } @@ -1118,7 +1125,7 @@ private ItemResponse PatchItemCore( new Dictionary(), null); if (!matches) { - throw new InMemoryCosmosException("Precondition Failed", + throw new InMemoryCosmosException(MessagePreconditionFailed, HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); } } @@ -1208,16 +1215,19 @@ public override async Task CreateItemStreamAsync( lock (_uniqueKeyWriteLock) { if (!ValidateUniqueKeysStream(jObj, pk)) - return CreateResponseMessage(HttpStatusCode.Conflict); + return CreateResponseMessage(HttpStatusCode.Conflict, + reason: $"{MessageEntityAlreadyExists} id = {itemId}"); if (!_items.TryAdd(key, json)) - return CreateResponseMessage(HttpStatusCode.Conflict); + return CreateResponseMessage(HttpStatusCode.Conflict, + reason: $"{MessageEntityAlreadyExists} id = {itemId}"); } } else { if (!_items.TryAdd(key, json)) - return CreateResponseMessage(HttpStatusCode.Conflict); + return CreateResponseMessage(HttpStatusCode.Conflict, + reason: $"{MessageEntityAlreadyExists} id = {itemId}"); } var etag = GenerateETag(); @@ -1258,7 +1268,8 @@ public override Task ReadItemStreamAsync( if (!_items.TryGetValue(key, out var json) || IsExpired(key)) { EvictIfExpired(key); - return Task.FromResult(CreateResponseMessage(HttpStatusCode.NotFound)); + return Task.FromResult(CreateResponseMessage(HttpStatusCode.NotFound, + reason: $"{MessageEntityDoesNotExist} id = {id}")); } var etag = _etags.GetValueOrDefault(key); if (!CheckIfNoneMatchStream(requestOptions, key)) @@ -1306,12 +1317,14 @@ public override async Task UpsertItemStreamAsync( // IfMatch on non-existent item: return NotFound (matching real Cosmos) if (requestOptions?.IfMatchEtag is not null && !_items.ContainsKey(key)) { - return CreateResponseMessage(HttpStatusCode.NotFound); + return CreateResponseMessage(HttpStatusCode.NotFound, + reason: $"{MessageEntityDoesNotExist} id = {itemId}"); } if (!CheckIfMatchStream(requestOptions, key)) { - return CreateResponseMessage(HttpStatusCode.PreconditionFailed); + return CreateResponseMessage(HttpStatusCode.PreconditionFailed, + reason: MessagePreconditionFailed); } bool existed; string etag; @@ -1324,7 +1337,8 @@ public override async Task UpsertItemStreamAsync( lock (_uniqueKeyWriteLock) { if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: itemId)) - return CreateResponseMessage(HttpStatusCode.Conflict); + return CreateResponseMessage(HttpStatusCode.Conflict, + reason: $"{MessageEntityAlreadyExists} id = {itemId}"); existed = _items.TryGetValue(key, out previousJson); if (existed) { previousEtag = _etags.GetValueOrDefault(key); previousTimestamp = _timestamps.GetValueOrDefault(key); } etag = GenerateETag(); @@ -1337,7 +1351,8 @@ public override async Task UpsertItemStreamAsync( else { if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: itemId)) - return CreateResponseMessage(HttpStatusCode.Conflict); + return CreateResponseMessage(HttpStatusCode.Conflict, + reason: $"{MessageEntityAlreadyExists} id = {itemId}"); existed = _items.TryGetValue(key, out previousJson); if (existed) { previousEtag = _etags.GetValueOrDefault(key); previousTimestamp = _timestamps.GetValueOrDefault(key); } etag = GenerateETag(); @@ -1412,12 +1427,14 @@ public override async Task ReplaceItemStreamAsync( if (!_items.TryGetValue(key, out var previousJson) || IsExpired(key)) { EvictIfExpired(key); - return CreateResponseMessage(HttpStatusCode.NotFound); + return CreateResponseMessage(HttpStatusCode.NotFound, + reason: $"{MessageEntityDoesNotExist} id = {id}"); } if (!CheckIfMatchStream(requestOptions, key)) { - return CreateResponseMessage(HttpStatusCode.PreconditionFailed); + return CreateResponseMessage(HttpStatusCode.PreconditionFailed, + reason: MessagePreconditionFailed); } var previousEtag = _etags.GetValueOrDefault(key); @@ -1437,7 +1454,8 @@ public override async Task ReplaceItemStreamAsync( lock (_uniqueKeyWriteLock) { if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: id)) - return CreateResponseMessage(HttpStatusCode.Conflict); + return CreateResponseMessage(HttpStatusCode.Conflict, + reason: $"{MessageEntityAlreadyExists} id = {id}"); etag = GenerateETag(); _etags[key] = etag; _timestamps[key] = DateTimeOffset.UtcNow; @@ -1448,7 +1466,8 @@ public override async Task ReplaceItemStreamAsync( else { if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: id)) - return CreateResponseMessage(HttpStatusCode.Conflict); + return CreateResponseMessage(HttpStatusCode.Conflict, + reason: $"{MessageEntityAlreadyExists} id = {id}"); etag = GenerateETag(); _etags[key] = etag; _timestamps[key] = DateTimeOffset.UtcNow; @@ -1494,12 +1513,14 @@ public override async Task DeleteItemStreamAsync( if (!_items.TryGetValue(key, out var existingJson) || IsExpired(key)) { EvictIfExpired(key); - return CreateResponseMessage(HttpStatusCode.NotFound); + return CreateResponseMessage(HttpStatusCode.NotFound, + reason: $"{MessageEntityDoesNotExist} id = {id}"); } if (!CheckIfMatchStream(requestOptions, key)) { - return CreateResponseMessage(HttpStatusCode.PreconditionFailed); + return CreateResponseMessage(HttpStatusCode.PreconditionFailed, + reason: MessagePreconditionFailed); } ExecutePreTriggers(requestOptions, JsonParseHelpers.ParseJson(existingJson), "Delete"); @@ -1582,12 +1603,14 @@ private ResponseMessage PatchItemStreamCore( if (!_items.TryGetValue(key, out var existingJson) || IsExpired(key)) { EvictIfExpired(key); - return CreateResponseMessage(HttpStatusCode.NotFound); + return CreateResponseMessage(HttpStatusCode.NotFound, + reason: $"{MessageEntityDoesNotExist} id = {id}"); } if (!CheckIfMatchStream(requestOptions, key)) { - return CreateResponseMessage(HttpStatusCode.PreconditionFailed); + return CreateResponseMessage(HttpStatusCode.PreconditionFailed, + reason: MessagePreconditionFailed); } var jObj = JsonParseHelpers.ParseJson(existingJson); @@ -1602,7 +1625,8 @@ private ResponseMessage PatchItemStreamCore( new Dictionary(), null); if (!matches) { - return CreateResponseMessage(HttpStatusCode.PreconditionFailed); + return CreateResponseMessage(HttpStatusCode.PreconditionFailed, + reason: MessagePreconditionFailed); } } } @@ -1625,7 +1649,8 @@ private ResponseMessage PatchItemStreamCore( lock (_uniqueKeyWriteLock) { if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: id)) - return CreateResponseMessage(HttpStatusCode.Conflict); + return CreateResponseMessage(HttpStatusCode.Conflict, + reason: $"{MessageEntityAlreadyExists} id = {id}"); etag = GenerateETag(); _etags[key] = etag; _timestamps[key] = DateTimeOffset.UtcNow; @@ -3094,7 +3119,7 @@ private void CheckIfMatch(ItemRequestOptions requestOptions, (string Id, string if (requestOptions.IfMatchEtag != currentEtag) { - throw new InMemoryCosmosException("Precondition Failed", HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + throw new InMemoryCosmosException(MessagePreconditionFailed, HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); } } @@ -3129,12 +3154,12 @@ private void CheckIfNoneMatchForWrite(ItemRequestOptions requestOptions, (string if (requestOptions.IfNoneMatchEtag == "*" && _etags.ContainsKey(key)) { - throw new InMemoryCosmosException("Precondition Failed", HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + throw new InMemoryCosmosException(MessagePreconditionFailed, HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); } if (_etags.TryGetValue(key, out var currentEtag) && requestOptions.IfNoneMatchEtag == currentEtag) { - throw new InMemoryCosmosException("Precondition Failed", HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + throw new InMemoryCosmosException(MessagePreconditionFailed, HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); } } @@ -3245,14 +3270,23 @@ public InMemoryItemResponse( public override string ETag => _etag; } - private ResponseMessage CreateResponseMessage(HttpStatusCode statusCode, string json = null, string etag = null, int subStatusCode = 0) + private ResponseMessage CreateResponseMessage(HttpStatusCode statusCode, string json = null, string etag = null, int subStatusCode = 0, string reason = null) { var errorMessage = (int)statusCode >= 400 ? $"Response status code does not indicate success: {statusCode} ({(int)statusCode})" : null; + + // When a specific error reason is provided, embed it as a JSON body so the Cosmos SDK + // can extract it into CosmosException.Message when this response flows through the + // FakeCosmosHandler HTTP pipeline (ConvertToHttpResponse → SDK parses response body). + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/ — error response body format. + var contentJson = json ?? (reason is not null + ? new JObject { ["message"] = reason }.ToString(Formatting.None) + : null); + var msg = new ResponseMessage(statusCode, requestMessage: null, errorMessage: errorMessage) { - Content = json is not null ? ToStream(json) : null + Content = contentJson is not null ? ToStream(contentJson) : null }; msg.Headers["x-ms-activity-id"] = Guid.NewGuid().ToString(); msg.Headers["x-ms-request-charge"] = SyntheticRequestCharge.ToString(CultureInfo.InvariantCulture); diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 0d197fa..478c359 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ true - 4.0.5 + 4.0.6 lemonlion Copyright (c) 2026 lemonlion MIT diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ResponseMetadataTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ResponseMetadataTests.cs index fe05357..18f2919 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ResponseMetadataTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ResponseMetadataTests.cs @@ -2014,26 +2014,35 @@ public class StreamErrorBodyContentDeepDiveTests private readonly InMemoryContainer _container = new("test", "/partitionKey"); [Fact] - public async Task StreamError_404_Content_IsNull() + public async Task StreamError_404_Content_ContainsJsonMessage() { var response = await _container.ReadItemStreamAsync("nope", new PartitionKey("pk")); response.StatusCode.Should().Be(HttpStatusCode.NotFound); - response.Content.Should().BeNull(); + // Error responses now include a JSON body so the Cosmos SDK can surface + // a meaningful message in CosmosException.Message when flowing through + // the FakeCosmosHandler HTTP pipeline. + response.Content.Should().NotBeNull(); + using var reader = new StreamReader(response.Content!); + var body = await reader.ReadToEndAsync(); + JObject.Parse(body)["message"]!.ToString().Should().Contain("in the system"); } [Fact] - public async Task StreamError_409_Content_IsNull() + public async Task StreamError_409_Content_ContainsJsonMessage() { await _container.CreateItemStreamAsync( new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); var response = await _container.CreateItemStreamAsync( new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); response.StatusCode.Should().Be(HttpStatusCode.Conflict); - response.Content.Should().BeNull(); + response.Content.Should().NotBeNull(); + using var reader = new StreamReader(response.Content!); + var body = await reader.ReadToEndAsync(); + JObject.Parse(body)["message"]!.ToString().Should().NotBeNullOrEmpty(); } [Fact] - public async Task StreamError_412_Content_IsNull() + public async Task StreamError_412_Content_ContainsJsonMessage() { await _container.CreateItemStreamAsync( new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); @@ -2042,7 +2051,10 @@ await _container.CreateItemStreamAsync( "1", new PartitionKey("pk"), new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - response.Content.Should().BeNull(); + response.Content.Should().NotBeNull(); + using var reader = new StreamReader(response.Content!); + var body = await reader.ReadToEndAsync(); + JObject.Parse(body)["message"]!.ToString().Should().Contain("One of the specified pre-condition is not met"); } } @@ -2476,3 +2488,314 @@ public async Task FakeCosmosHandler_HasSessionToken_OnResponse() response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); } } + +// ═══════════════════════════════════════════════════════════════════════════════ +// Exception Message Consistency Tests +// Verify that all typed-API and stream-API paths use the same error messages +// so that callers who inspect ex.Message get consistent, meaningful text. +// ═══════════════════════════════════════════════════════════════════════════════ + +/// +/// Tests that all typed-API operations throw NotFound (404) with "in the system" +/// in the message — matching the real Cosmos DB REST API response body. +/// Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/ +/// +public class TypedApiNotFoundMessageTests +{ + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task ReadItemAsync_NotFound_MessageContainsInTheSystem() + { + var ex = await Assert.ThrowsAnyAsync(() => + _container.ReadItemAsync("nope", new PartitionKey("pk"))); + + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + ex.Message.Should().Contain("in the system"); + } + + [Fact] + public async Task ReplaceItemAsync_NotFound_MessageContainsInTheSystem() + { + var ex = await Assert.ThrowsAnyAsync(() => + _container.ReplaceItemAsync( + new TestDocument { Id = "nope", PartitionKey = "pk", Name = "X" }, + "nope", new PartitionKey("pk"))); + + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + ex.Message.Should().Contain("in the system"); + } + + [Fact] + public async Task DeleteItemAsync_NotFound_MessageContainsInTheSystem() + { + var ex = await Assert.ThrowsAnyAsync(() => + _container.DeleteItemAsync("nope", new PartitionKey("pk"))); + + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + ex.Message.Should().Contain("in the system"); + } + + [Fact] + public async Task UpsertItemAsync_WithIfMatchEtag_NotFound_MessageContainsInTheSystem() + { + // IfMatchEtag on non-existent item → 404 NotFound + var ex = await Assert.ThrowsAnyAsync(() => + _container.UpsertItemAsync( + new TestDocument { Id = "nope", PartitionKey = "pk", Name = "X" }, + new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"any-etag\"" })); + + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + ex.Message.Should().Contain("in the system"); + } + + [Fact] + public async Task PatchItemAsync_NotFound_MessageContainsInTheSystem() + { + var ex = await Assert.ThrowsAnyAsync(() => + _container.PatchItemAsync("nope", new PartitionKey("pk"), + [PatchOperation.Set("/name", "X")])); + + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + ex.Message.Should().Contain("in the system"); + } +} + +/// +/// Tests that all typed-API operations throw PreconditionFailed (412) with +/// the correct message matching the real Cosmos DB REST API response body. +/// Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/ +/// +public class TypedApiPreconditionFailedMessageTests +{ + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task ReplaceItemAsync_StaleETag_PreconditionFailed_MessageMatchesRealCosmos() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var ex = await Assert.ThrowsAnyAsync(() => + _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, + "1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" })); + + ex.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/replace-a-document + ex.Message.Should().Contain("One of the specified pre-condition is not met"); + } + + [Fact] + public async Task DeleteItemAsync_StaleETag_PreconditionFailed_MessageMatchesRealCosmos() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var ex = await Assert.ThrowsAnyAsync(() => + _container.DeleteItemAsync("1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" })); + + ex.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + ex.Message.Should().Contain("One of the specified pre-condition is not met"); + } + + [Fact] + public async Task PatchItemAsync_StaleETag_PreconditionFailed_MessageMatchesRealCosmos() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var ex = await Assert.ThrowsAnyAsync(() => + _container.PatchItemAsync("1", new PartitionKey("pk"), + [PatchOperation.Set("/name", "B")], + new PatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" })); + + ex.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + ex.Message.Should().Contain("One of the specified pre-condition is not met"); + } + + [Fact] + public async Task PatchItemAsync_FilterPredicateNotMet_PreconditionFailed_MessageMatchesRealCosmos() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var ex = await Assert.ThrowsAnyAsync(() => + _container.PatchItemAsync("1", new PartitionKey("pk"), + [PatchOperation.Set("/name", "B")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.name = 'X'" })); + + ex.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + ex.Message.Should().Contain("One of the specified pre-condition is not met"); + } +} + +/// +/// Tests that stream-API error responses include a JSON body with a "message" +/// field so that errors are surfaced correctly through the FakeCosmosHandler +/// HTTP pipeline (where the Cosmos SDK converts HTTP responses to CosmosExceptions). +/// +public class StreamApiErrorResponseBodyTests +{ + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + private static MemoryStream MakeStream(string json) => + new(Encoding.UTF8.GetBytes(json)); + + private static async Task ReadBodyAsync(ResponseMessage response) + { + if (response.Content is null) return null; + using var reader = new StreamReader(response.Content); + return await reader.ReadToEndAsync(); + } + + [Fact] + public async Task ReadItemStream_NotFound_ResponseBodyContainsMessage() + { + var response = await _container.ReadItemStreamAsync("nope", new PartitionKey("pk")); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + var body = await ReadBodyAsync(response); + body.Should().NotBeNullOrEmpty(); + var json = JObject.Parse(body!); + json["message"]!.ToString().Should().Contain("in the system"); + } + + [Fact] + public async Task ReplaceItemStream_NotFound_ResponseBodyContainsMessage() + { + var response = await _container.ReplaceItemStreamAsync( + MakeStream("""{"id":"nope","partitionKey":"pk"}"""), "nope", new PartitionKey("pk")); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + var body = await ReadBodyAsync(response); + body.Should().NotBeNullOrEmpty(); + JObject.Parse(body!)["message"]!.ToString().Should().Contain("in the system"); + } + + [Fact] + public async Task DeleteItemStream_NotFound_ResponseBodyContainsMessage() + { + var response = await _container.DeleteItemStreamAsync("nope", new PartitionKey("pk")); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + var body = await ReadBodyAsync(response); + body.Should().NotBeNullOrEmpty(); + JObject.Parse(body!)["message"]!.ToString().Should().Contain("in the system"); + } + + [Fact] + public async Task UpsertItemStream_WithIfMatchEtag_NotFound_ResponseBodyContainsMessage() + { + var response = await _container.UpsertItemStreamAsync( + MakeStream("""{"id":"nope","partitionKey":"pk"}"""), + new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"any-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + var body = await ReadBodyAsync(response); + body.Should().NotBeNullOrEmpty(); + JObject.Parse(body!)["message"]!.ToString().Should().Contain("in the system"); + } + + [Fact] + public async Task PatchItemStream_NotFound_ResponseBodyContainsMessage() + { + var response = await _container.PatchItemStreamAsync("nope", new PartitionKey("pk"), + [PatchOperation.Set("/name", "X")]); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + var body = await ReadBodyAsync(response); + body.Should().NotBeNullOrEmpty(); + JObject.Parse(body!)["message"]!.ToString().Should().Contain("in the system"); + } + + [Fact] + public async Task CreateItemStream_Conflict_ResponseBodyContainsMessage() + { + await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), new PartitionKey("pk")); + var response = await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), new PartitionKey("pk")); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + var body = await ReadBodyAsync(response); + body.Should().NotBeNullOrEmpty(); + JObject.Parse(body!)["message"]!.ToString().Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ReplaceItemStream_StaleETag_PreconditionFailed_ResponseBodyContainsMessage() + { + await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), new PartitionKey("pk")); + var response = await _container.ReplaceItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk","name":"B"}"""), + "1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + var body = await ReadBodyAsync(response); + body.Should().NotBeNullOrEmpty(); + JObject.Parse(body!)["message"]!.ToString().Should().Contain("One of the specified pre-condition is not met"); + } + + [Fact] + public async Task DeleteItemStream_StaleETag_PreconditionFailed_ResponseBodyContainsMessage() + { + await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), new PartitionKey("pk")); + var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + var body = await ReadBodyAsync(response); + body.Should().NotBeNullOrEmpty(); + JObject.Parse(body!)["message"]!.ToString().Should().Contain("One of the specified pre-condition is not met"); + } +} + +/// +/// Tests that exception messages are consistent across all typed-API operations +/// (no operation should use a different message format than the others). +/// +public class TypedApiMessageConsistencyTests +{ + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task AllNotFoundOperations_UseConsistentMessage_WithInTheSystem() + { + // All typed operations should produce NotFound messages containing "in the system" + var readEx = await Assert.ThrowsAnyAsync(() => + _container.ReadItemAsync("nope", new PartitionKey("pk"))); + + var replaceEx = await Assert.ThrowsAnyAsync(() => + _container.ReplaceItemAsync( + new TestDocument { Id = "nope", PartitionKey = "pk", Name = "X" }, + "nope", new PartitionKey("pk"))); + + var deleteEx = await Assert.ThrowsAnyAsync(() => + _container.DeleteItemAsync("nope", new PartitionKey("pk"))); + + var patchEx = await Assert.ThrowsAnyAsync(() => + _container.PatchItemAsync("nope", new PartitionKey("pk"), + [PatchOperation.Set("/name", "X")])); + + // All must contain "in the system" + readEx.Message.Should().Contain("in the system"); + replaceEx.Message.Should().Contain("in the system"); + deleteEx.Message.Should().Contain("in the system"); + patchEx.Message.Should().Contain("in the system"); + + // All must start with the same prefix + const string expectedPrefix = "Entity with the specified id does not exist in the system."; + readEx.Message.Should().StartWith(expectedPrefix); + replaceEx.Message.Should().StartWith(expectedPrefix); + deleteEx.Message.Should().StartWith(expectedPrefix); + patchEx.Message.Should().StartWith(expectedPrefix); + } +}