diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/ETagRedTeamEdgeCaseTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/ETagRedTeamEdgeCaseTests.cs new file mode 100644 index 0000000..5457ac8 --- /dev/null +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/ETagRedTeamEdgeCaseTests.cs @@ -0,0 +1,1445 @@ +using AwesomeAssertions; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json.Linq; +using System.Net; +using System.Text; +using Xunit; + +namespace CosmosDB.InMemoryEmulator.Tests; + +/// +/// Red-team edge case tests for ETag/IfMatchEtag implementation. +/// Designed to find subtle bugs in concurrency, format handling, +/// cross-API consistency, and state management. +/// +public class ETagRedTeamEdgeCaseTests : IDisposable +{ + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("etag-redteam", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); + + // ═══════════════════════════════════════════════════════════════════════════ + // 1. ETag Format Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task IfMatchEtag_EmptyString_IsIgnoredByPipeline() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "fmt-1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + // Through the SDK pipeline, empty string is not a valid HTTP ETag header value. + // The SDK/HTTP layer drops it, so the operation proceeds without an ETag check. + var response = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "fmt-1", PartitionKey = "pk1", Name = "Updated" }, + "fmt-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task IfMatchEtag_WhitespaceOnly_IsIgnoredByPipeline() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "fmt-2", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + // Through the SDK pipeline, whitespace-only is not a valid HTTP ETag header value. + // The SDK/HTTP layer drops it, so the operation proceeds without an ETag check. + var response = await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "fmt-2", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = " " }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task IfMatchEtag_WithoutQuotes_IsIgnoredByPipeline() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "fmt-3", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + // ETags are generated as quoted hex strings; unquoted version is not a valid + // HTTP ETag format. Through the SDK pipeline, the HTTP layer drops it, + // so the operation proceeds without an ETag check. + var quotedEtag = create.ETag; + var unquotedEtag = quotedEtag.Trim('"'); + + var response = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "fmt-3", PartitionKey = "pk1", Name = "Updated" }, + "fmt-3", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = unquotedEtag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task IfMatchEtag_VeryLongString_ShouldReject() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "fmt-4", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var longEtag = "\"" + new string('a', 10000) + "\""; + + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "fmt-4", PartitionKey = "pk1", Name = "Updated" }, + "fmt-4", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = longEtag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task IfMatchEtag_SpecialCharacters_ShouldReject() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "fmt-5", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "fmt-5", PartitionKey = "pk1", Name = "Updated" }, + "fmt-5", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task IfMatchEtag_CaseSensitivity_ETagsShouldBeCaseSensitive() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "fmt-6", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var etag = create.ETag; + var upperCaseEtag = etag.ToUpperInvariant(); + + // If the generated etag has lowercase hex, upper-casing it should fail + if (etag != upperCaseEtag) + { + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "fmt-6", PartitionKey = "pk1", Name = "Updated" }, + "fmt-6", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = upperCaseEtag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 2. Wildcard Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task IfMatchEtag_WildcardStar_OnNonExistentItem_ReplaceShouldFail() + { + // Wildcard "*" means "match any version" but the item must still exist for Replace + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "wild-1", PartitionKey = "pk1", Name = "New" }, + "wild-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task IfMatchEtag_WildcardStar_OnDeletedItem_ReplaceShouldFail() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "wild-2", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + await _cosmos.Container.DeleteItemAsync("wild-2", new PartitionKey("pk1")); + + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "wild-2", PartitionKey = "pk1", Name = "Updated" }, + "wild-2", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task IfMatchEtag_WildcardStar_OnDeletedItem_DeleteShouldFail() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "wild-3", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + await _cosmos.Container.DeleteItemAsync("wild-3", new PartitionKey("pk1")); + + var act = () => _cosmos.Container.DeleteItemAsync( + "wild-3", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task IfMatchEtag_QuotedWildcard_ShouldNotBeWildcard() + { + // "\"*\"" is a literal etag value, not the wildcard * + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "wild-4", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "wild-4", PartitionKey = "pk1", Name = "Updated" }, + "wild-4", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"*\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task IfMatchEtag_WildcardStar_UpsertNonExistentItem_ShouldCreateItem() + { + // Per REST API docs: IfMatch applies to PUT/DELETE only. + // Upsert on a non-existent item uses POST semantics — IfMatchEtag (including wildcard) is ignored. + var response = await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "wild-5", PartitionKey = "pk1", Name = "New" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 3. Delete-then-Write Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task DeleteThenReplace_WithOldETag_ShouldFail() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "del-1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var originalEtag = create.ETag; + + await _cosmos.Container.DeleteItemAsync("del-1", new PartitionKey("pk1")); + + // Replace with old ETag on deleted item should be NotFound (item doesn't exist) + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "del-1", PartitionKey = "pk1", Name = "Resurrected" }, + "del-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = originalEtag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteThenUpsert_WithOldETag_CreatesItem() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "del-2", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var originalEtag = create.ETag; + + await _cosmos.Container.DeleteItemAsync("del-2", new PartitionKey("pk1")); + + // Per REST API docs: IfMatch applies to PUT/DELETE only. Upsert uses POST semantics on + // the insert path, so IfMatchEtag is ignored when the item doesn't exist. + var response = await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "del-2", PartitionKey = "pk1", Name = "Resurrected" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = originalEtag }); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task DeleteThenRecreate_OldETag_ShouldNotMatchNewItem() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "del-3", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var originalEtag = create.ETag; + + await _cosmos.Container.DeleteItemAsync("del-3", new PartitionKey("pk1")); + + // Recreate same item + var recreate = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "del-3", PartitionKey = "pk1", Name = "Recreated" }, + new PartitionKey("pk1")); + + // Old ETag should not match new item's ETag + recreate.ETag.Should().NotBe(originalEtag); + + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "del-3", PartitionKey = "pk1", Name = "Update with old etag" }, + "del-3", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = originalEtag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task DeleteWithMatchingETag_ShouldSucceed() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "del-4", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var etag = create.ETag; + + // Delete with current ETag should succeed + var response = await _cosmos.Container.DeleteItemAsync( + "del-4", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeleteWithStaleETag_ShouldFail() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "del-5", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var staleEtag = create.ETag; + + // Modify the item to change its ETag + await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "del-5", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + + var act = () => _cosmos.Container.DeleteItemAsync( + "del-5", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = staleEtag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 4. Cross-Partition Key Scoping + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ETags_AreScopedToPartitionKey_SameIdDifferentPK() + { + var create1 = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "xpk-1", PartitionKey = "pkA", Name = "Item A" }, + new PartitionKey("pkA")); + + var create2 = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "xpk-1", PartitionKey = "pkB", Name = "Item B" }, + new PartitionKey("pkB")); + + // ETags should be different even for same ID with different partition keys + create1.ETag.Should().NotBe(create2.ETag); + + // Using ETag from pkA should NOT work for pkB + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "xpk-1", PartitionKey = "pkB", Name = "Updated B" }, + "xpk-1", new PartitionKey("pkB"), + new ItemRequestOptions { IfMatchEtag = create1.ETag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task ETags_AreScopedToPartitionKey_UpdateOneDoesNotAffectOther() + { + var createA = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "xpk-2", PartitionKey = "pkA", Name = "Item A" }, + new PartitionKey("pkA")); + + var createB = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "xpk-2", PartitionKey = "pkB", Name = "Item B" }, + new PartitionKey("pkB")); + + var etagB = createB.ETag; + + // Update item in pkA + await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "xpk-2", PartitionKey = "pkA", Name = "Updated A" }, + "xpk-2", new PartitionKey("pkA")); + + // ETag for pkB should still be valid + var replaceB = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "xpk-2", PartitionKey = "pkB", Name = "Updated B" }, + "xpk-2", new PartitionKey("pkB"), + new ItemRequestOptions { IfMatchEtag = etagB }); + + replaceB.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 5. Patch Operations with IfMatchEtag + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Patch_WithMatchingETag_ShouldSucceed() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "patch-1", PartitionKey = "pk1", Name = "Original", Value = 1 }, + new PartitionKey("pk1")); + + var response = await _cosmos.Container.PatchItemAsync( + "patch-1", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Patched") }, + new PatchItemRequestOptions { IfMatchEtag = create.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Patched"); + } + + [Fact] + public async Task Patch_WithStaleETag_ShouldFail() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "patch-2", PartitionKey = "pk1", Name = "Original", Value = 1 }, + new PartitionKey("pk1")); + var staleEtag = create.ETag; + + await _cosmos.Container.PatchItemAsync( + "patch-2", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Updated") }); + + var act = () => _cosmos.Container.PatchItemAsync( + "patch-2", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Second Update") }, + new PatchItemRequestOptions { IfMatchEtag = staleEtag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Patch_WithWildcardETag_ShouldSucceed() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "patch-3", PartitionKey = "pk1", Name = "Original", Value = 1 }, + new PartitionKey("pk1")); + + var response = await _cosmos.Container.PatchItemAsync( + "patch-3", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Patched") }, + new PatchItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Patch_OnNonExistentItem_WithETag_ShouldReturnNotFound() + { + var act = () => _cosmos.Container.PatchItemAsync( + "patch-nope", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Patched") }, + new PatchItemRequestOptions { IfMatchEtag = "\"some-fake-etag\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Patch_ETagChangesAfterPatch() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "patch-4", PartitionKey = "pk1", Name = "Original", Value = 1 }, + new PartitionKey("pk1")); + + var patchResult = await _cosmos.Container.PatchItemAsync( + "patch-4", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Patched") }); + + patchResult.ETag.Should().NotBe(create.ETag); + + // Old ETag should no longer work + var act = () => _cosmos.Container.PatchItemAsync( + "patch-4", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Second") }, + new PatchItemRequestOptions { IfMatchEtag = create.ETag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 6. Stream vs Typed API Consistency + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task StreamReplace_WithMatchingETag_ShouldSucceed() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "stream-1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + using var stream = new MemoryStream(Encoding.UTF8.GetBytes( + "{\"id\":\"stream-1\",\"partitionKey\":\"pk1\",\"name\":\"Stream Updated\"}")); + var response = await _cosmos.Container.ReplaceItemStreamAsync( + stream, "stream-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = create.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task StreamUpsert_WithMatchingETag_ShouldSucceed() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "stream-2", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + using var stream = new MemoryStream(Encoding.UTF8.GetBytes( + "{\"id\":\"stream-2\",\"partitionKey\":\"pk1\",\"name\":\"Stream Updated\"}")); + var response = await _cosmos.Container.UpsertItemStreamAsync( + stream, new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = create.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task StreamDelete_WithMatchingETag_ShouldSucceed() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "stream-3", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var response = await _cosmos.Container.DeleteItemStreamAsync( + "stream-3", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = create.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task StreamDelete_WithStaleETag_ShouldReturn412() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "stream-4", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var staleEtag = create.ETag; + + await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "stream-4", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + + var response = await _cosmos.Container.DeleteItemStreamAsync( + "stream-4", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = staleEtag }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task StreamUpsert_OnNonExistentItem_WithETag_CreatesItem() + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes( + "{\"id\":\"stream-nope\",\"partitionKey\":\"pk1\",\"name\":\"New\"}")); + var response = await _cosmos.Container.UpsertItemStreamAsync( + stream, new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"fake-etag\"" }); + + // Per REST API docs: IfMatch is ignored on the Upsert insert path (POST semantics). + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task StreamReplace_OnDeletedItem_WithOldETag_ShouldReturnNotFound() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "stream-5", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var etag = create.ETag; + + await _cosmos.Container.DeleteItemAsync("stream-5", new PartitionKey("pk1")); + + using var stream = new MemoryStream(Encoding.UTF8.GetBytes( + "{\"id\":\"stream-5\",\"partitionKey\":\"pk1\",\"name\":\"Resurrected\"}")); + var response = await _cosmos.Container.ReplaceItemStreamAsync( + stream, "stream-5", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task StreamPatch_WithStaleETag_ShouldReturn412() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "stream-6", PartitionKey = "pk1", Name = "Original", Value = 1 }, + new PartitionKey("pk1")); + var staleEtag = create.ETag; + + await _cosmos.Container.PatchItemAsync( + "stream-6", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Updated") }); + + var response = await _cosmos.Container.PatchItemStreamAsync( + "stream-6", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Should fail") }, + new PatchItemRequestOptions { IfMatchEtag = staleEtag }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task StreamPatch_WithMatchingETag_ShouldSucceed() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "stream-7", PartitionKey = "pk1", Name = "Original", Value = 1 }, + new PartitionKey("pk1")); + + var response = await _cosmos.Container.PatchItemStreamAsync( + "stream-7", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Patched via stream") }, + new PatchItemRequestOptions { IfMatchEtag = create.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 7. Stream Wildcard Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task StreamReplace_Wildcard_OnNonExistentItem_ShouldReturn404() + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes( + "{\"id\":\"sw-1\",\"partitionKey\":\"pk1\",\"name\":\"New\"}")); + var response = await _cosmos.Container.ReplaceItemStreamAsync( + stream, "sw-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task StreamDelete_Wildcard_OnNonExistentItem_ShouldReturn404() + { + var response = await _cosmos.Container.DeleteItemStreamAsync( + "sw-2", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task StreamUpsert_Wildcard_OnNonExistentItem_CreatesItem() + { + // Per REST API docs: IfMatch is ignored on the Upsert insert path (POST semantics, even wildcard). + using var stream = new MemoryStream(Encoding.UTF8.GetBytes( + "{\"id\":\"sw-3\",\"partitionKey\":\"pk1\",\"name\":\"New\"}")); + var response = await _cosmos.Container.UpsertItemStreamAsync( + stream, new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 8. Concurrency / Read-Write Interleaving + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ConcurrentReplaces_WithSameETag_OnlyOneShouldSucceed() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "conc-1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var etag = create.ETag; + + var tasks = Enumerable.Range(0, 10).Select(i => + _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "conc-1", PartitionKey = "pk1", Name = $"Writer-{i}" }, + "conc-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }) + ).Select(async t => + { + try + { + var r = await t; + return (Success: true, StatusCode: r.StatusCode); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) + { + return (Success: false, StatusCode: ex.StatusCode); + } + }).ToList(); + + var results = await Task.WhenAll(tasks); + results.Count(r => r.Success).Should().Be(1, "exactly one concurrent writer should succeed"); + results.Count(r => !r.Success).Should().Be(9, "remaining writers should get 412"); + } + + [Fact] + public async Task ConcurrentUpserts_WithSameETag_OnlyOneShouldSucceed() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "conc-2", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var etag = create.ETag; + + var tasks = Enumerable.Range(0, 10).Select(i => + _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "conc-2", PartitionKey = "pk1", Name = $"Writer-{i}" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }) + ).Select(async t => + { + try + { + var r = await t; + return (Success: true, StatusCode: r.StatusCode); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) + { + return (Success: false, StatusCode: ex.StatusCode); + } + }).ToList(); + + var results = await Task.WhenAll(tasks); + results.Count(r => r.Success).Should().Be(1, "exactly one concurrent writer should succeed"); + } + + [Fact] + public async Task ReadAfterReplace_ShouldReturnNewETag() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "conc-3", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var replace = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "conc-3", PartitionKey = "pk1", Name = "Updated" }, + "conc-3", new PartitionKey("pk1")); + + var read = await _cosmos.Container.ReadItemAsync("conc-3", new PartitionKey("pk1")); + + read.ETag.Should().Be(replace.ETag); + read.ETag.Should().NotBe(create.ETag); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 9. IfNoneMatch Interactions + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task IfNoneMatch_WithCurrentETag_Read_ShouldReturn304() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "inm-1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = () => _cosmos.Container.ReadItemAsync( + "inm-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = create.ETag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task IfNoneMatch_WithStaleETag_Read_ShouldSucceed() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "inm-2", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + var staleEtag = create.ETag; + + await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "inm-2", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + + var read = await _cosmos.Container.ReadItemAsync( + "inm-2", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = staleEtag }); + + read.StatusCode.Should().Be(HttpStatusCode.OK); + read.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task IfNoneMatch_Wildcard_OnExistingItem_Read_ShouldReturn304() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "inm-3", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = () => _cosmos.Container.ReadItemAsync( + "inm-3", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task IfNoneMatch_Wildcard_Patch_OnExistingItem_SucceedsThroughPipeline() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "inm-4", PartitionKey = "pk1", Name = "Test", Value = 1 }, + new PartitionKey("pk1")); + + // Through the SDK pipeline, the Patch handler only propagates IfMatch headers, + // not IfNoneMatch. So IfNoneMatchEtag is effectively ignored for Patch operations. + var response = await _cosmos.Container.PatchItemAsync( + "inm-4", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Patched") }, + new PatchItemRequestOptions { IfNoneMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task IfNoneMatch_StreamRead_WithCurrentETag_ShouldReturn304() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "inm-5", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _cosmos.Container.ReadItemStreamAsync( + "inm-5", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = create.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task IfNoneMatch_StreamRead_WithStaleETag_ShouldReturn200() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "inm-6", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + var staleEtag = create.ETag; + + await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "inm-6", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + + var response = await _cosmos.Container.ReadItemStreamAsync( + "inm-6", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = staleEtag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 10. Batch / TransactionalBatch Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Batch_MixedOperations_OneStaleETag_ShouldRollbackAll() + { + // Create two items + var create1 = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "batch-1", PartitionKey = "bpk", Name = "Item 1" }, + new PartitionKey("bpk")); + var create2 = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "batch-2", PartitionKey = "bpk", Name = "Item 2" }, + new PartitionKey("bpk")); + + // Update item 2 to invalidate its ETag + await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "batch-2", PartitionKey = "bpk", Name = "Item 2 Updated" }, + new PartitionKey("bpk")); + + // Batch: valid replace on item 1, stale replace on item 2 + var batch = _cosmos.Container.CreateTransactionalBatch(new PartitionKey("bpk")); + batch.ReplaceItem("batch-1", + new TestDocument { Id = "batch-1", PartitionKey = "bpk", Name = "Batch Replace 1" }, + new TransactionalBatchItemRequestOptions { IfMatchEtag = create1.ETag }); + batch.ReplaceItem("batch-2", + new TestDocument { Id = "batch-2", PartitionKey = "bpk", Name = "Batch Replace 2" }, + new TransactionalBatchItemRequestOptions { IfMatchEtag = create2.ETag }); // stale + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + + // Item 1 should NOT have been updated (rollback) + var read1 = await _cosmos.Container.ReadItemAsync("batch-1", new PartitionKey("bpk")); + read1.Resource.Name.Should().Be("Item 1", "batch should have rolled back item 1 changes"); + } + + [Fact] + public async Task Batch_Delete_WithStaleETag_ShouldRollback() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "batch-3", PartitionKey = "bpk", Name = "Original" }, + new PartitionKey("bpk")); + var staleEtag = create.ETag; + + await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "batch-3", PartitionKey = "bpk", Name = "Updated" }, + new PartitionKey("bpk")); + + var batch = _cosmos.Container.CreateTransactionalBatch(new PartitionKey("bpk")); + batch.DeleteItem("batch-3", + new TransactionalBatchItemRequestOptions { IfMatchEtag = staleEtag }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + + // Item should still exist + var read = await _cosmos.Container.ReadItemAsync("batch-3", new PartitionKey("bpk")); + read.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task Batch_CreateThenReplaceWithETag_ShouldWork() + { + // Batch that creates an item and then tries to use the etag from the create + // This is tricky because the create ETag isn't known until execution + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "batch-4", PartitionKey = "bpk", Name = "Existing" }, + new PartitionKey("bpk")); + + var batch = _cosmos.Container.CreateTransactionalBatch(new PartitionKey("bpk")); + batch.ReplaceItem("batch-4", + new TestDocument { Id = "batch-4", PartitionKey = "bpk", Name = "Batch Updated" }, + new TransactionalBatchItemRequestOptions { IfMatchEtag = create.ETag }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task Batch_WildcardETag_Replace_ShouldSucceed() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "batch-5", PartitionKey = "bpk", Name = "Original" }, + new PartitionKey("bpk")); + + var batch = _cosmos.Container.CreateTransactionalBatch(new PartitionKey("bpk")); + batch.ReplaceItem("batch-5", + new TestDocument { Id = "batch-5", PartitionKey = "bpk", Name = "Wildcard Updated" }, + new TransactionalBatchItemRequestOptions { IfMatchEtag = "*" }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 11. Import/Export State Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ImportState_RegeneratesETags_OldETagsShouldNotWork() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "import-1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var originalEtag = create.ETag; + + // Export and reimport + var state = _cosmos.ExportState(); + _cosmos.ImportState(state); + + // Old ETag should not work after reimport + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "import-1", PartitionKey = "pk1", Name = "Updated" }, + "import-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = originalEtag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task ImportState_NewETagsShouldWork() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "import-2", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var state = _cosmos.ExportState(); + _cosmos.ImportState(state); + + // Read to get new ETag + var read = await _cosmos.Container.ReadItemAsync("import-2", new PartitionKey("pk1")); + var newEtag = read.ETag; + newEtag.Should().NotBeNullOrEmpty(); + + // New ETag should work + var replace = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "import-2", PartitionKey = "pk1", Name = "Updated after import" }, + "import-2", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = newEtag }); + + replace.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ImportState_EmptyImport_ShouldClearEverything() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "import-3", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + _cosmos.ImportState("{\"items\":[]}"); + + var act = () => _cosmos.Container.ReadItemAsync("import-3", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 12. ETag in Response Consistency + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ETag_ReturnedFromCreate_MatchesSubsequentRead() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "resp-1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var read = await _cosmos.Container.ReadItemAsync("resp-1", new PartitionKey("pk1")); + + read.ETag.Should().Be(create.ETag); + } + + [Fact] + public async Task ETag_ReturnedFromUpsert_MatchesSubsequentRead() + { + var upsert = await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "resp-2", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var read = await _cosmos.Container.ReadItemAsync("resp-2", new PartitionKey("pk1")); + + read.ETag.Should().Be(upsert.ETag); + } + + [Fact] + public async Task ETag_ReturnedFromReplace_MatchesSubsequentRead() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "resp-3", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var replace = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "resp-3", PartitionKey = "pk1", Name = "Replaced" }, + "resp-3", new PartitionKey("pk1")); + + var read = await _cosmos.Container.ReadItemAsync("resp-3", new PartitionKey("pk1")); + + read.ETag.Should().Be(replace.ETag); + } + + [Fact] + public async Task ETag_ReturnedFromPatch_MatchesSubsequentRead() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "resp-4", PartitionKey = "pk1", Name = "Original", Value = 1 }, + new PartitionKey("pk1")); + + var patch = await _cosmos.Container.PatchItemAsync( + "resp-4", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Patched") }); + + var read = await _cosmos.Container.ReadItemAsync("resp-4", new PartitionKey("pk1")); + + read.ETag.Should().Be(patch.ETag); + } + + [Fact] + public async Task ETag_StreamRead_MatchesTypedRead() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "resp-5", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var typedRead = await _cosmos.Container.ReadItemAsync("resp-5", new PartitionKey("pk1")); + var streamRead = await _cosmos.Container.ReadItemStreamAsync("resp-5", new PartitionKey("pk1")); + + streamRead.Headers.ETag.Should().Be(typedRead.ETag); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 13. ETag Uniqueness / Monotonicity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ETags_AreUniqueAcrossMultipleOperations() + { + var etags = new HashSet(); + + for (int i = 0; i < 50; i++) + { + var resp = await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "unique-1", PartitionKey = "pk1", Name = $"Version-{i}" }, + new PartitionKey("pk1")); + etags.Add(resp.ETag).Should().BeTrue($"ETag should be unique at iteration {i}"); + } + } + + [Fact] + public async Task ETags_AreUniqueAcrossMultipleItems() + { + var etags = new HashSet(); + + for (int i = 0; i < 50; i++) + { + var resp = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = $"unique-item-{i}", PartitionKey = "pk1", Name = $"Item-{i}" }, + new PartitionKey("pk1")); + etags.Add(resp.ETag).Should().BeTrue($"ETag should be unique for item {i}"); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 14. Double-Delete and Repeat Operations + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task DoubleDelete_WithSameETag_SecondShouldFail() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "dd-1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var etag = create.ETag; + + // First delete succeeds + await _cosmos.Container.DeleteItemAsync( + "dd-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + + // Second delete with same ETag should fail (item gone) + var act = () => _cosmos.Container.DeleteItemAsync( + "dd-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DoubleReplace_WithFirstETag_SecondShouldFail() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "dd-2", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var etag = create.ETag; + + // First replace succeeds and changes ETag + var replace1 = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "dd-2", PartitionKey = "pk1", Name = "First Replace" }, + "dd-2", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + + replace1.ETag.Should().NotBe(etag); + + // Second replace with original ETag should fail + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "dd-2", PartitionKey = "pk1", Name = "Second Replace" }, + "dd-2", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 15. Upsert Semantics: Create-via-Upsert with IfMatchEtag + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Upsert_AsCreate_WithIfMatchEtag_CreatesItem() + { + // Upsert that would create a new item, but IfMatchEtag is set + // Per REST API docs: IfMatch is ignored on the Upsert insert path (POST semantics). + // When the item doesn't exist, IfMatchEtag is not evaluated - item is created. + var response = await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "upsert-new-1", PartitionKey = "pk1", Name = "Brand New" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"definitely-not-a-real-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Upsert_AsUpdate_WithMatchingEtag_ShouldSucceed() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "upsert-upd-1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var upsert = await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "upsert-upd-1", PartitionKey = "pk1", Name = "Upserted" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = create.ETag }); + + upsert.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Upsert_WithNoEtag_ShouldAlwaysSucceedAsCreate() + { + var upsert = await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "upsert-no-etag", PartitionKey = "pk1", Name = "Created via Upsert" }, + new PartitionKey("pk1")); + + upsert.StatusCode.Should().Be(HttpStatusCode.Created); + upsert.ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Upsert_WithNoEtag_ShouldAlwaysSucceedAsUpdate() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "upsert-no-etag-2", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var upsert = await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "upsert-no-etag-2", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + + upsert.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 16. Rapid Read-Modify-Write Cycle (Optimistic Concurrency Pattern) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task OptimisticConcurrency_ReadModifyWrite_ShouldWork() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "occ-1", PartitionKey = "pk1", Name = "v0", Value = 0 }, + new PartitionKey("pk1")); + + // Simulate optimistic concurrency loop + for (int i = 0; i < 10; i++) + { + var read = await _cosmos.Container.ReadItemAsync("occ-1", new PartitionKey("pk1")); + var item = read.Resource; + item.Value++; + item.Name = $"v{item.Value}"; + + var response = await _cosmos.Container.ReplaceItemAsync(item, "occ-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = read.ETag }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + var final = await _cosmos.Container.ReadItemAsync("occ-1", new PartitionKey("pk1")); + final.Resource.Value.Should().Be(10); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 17. ETag in System Properties (_etag field in document) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ETag_InDocument_MatchesResponseHeader() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "sysprop-1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var streamRead = await _cosmos.Container.ReadItemStreamAsync("sysprop-1", new PartitionKey("pk1")); + using var reader = new StreamReader(streamRead.Content); + var json = await reader.ReadToEndAsync(); + var doc = JObject.Parse(json); + + var documentEtag = doc["_etag"]?.ToString(); + documentEtag.Should().NotBeNull(); + // The response ETag should match the _etag in the document body + create.ETag.Should().Be(documentEtag); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 18. Null RequestOptions + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Replace_WithNullRequestOptions_ShouldSucceed() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "null-opts-1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var response = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "null-opts-1", PartitionKey = "pk1", Name = "Updated" }, + "null-opts-1", new PartitionKey("pk1"), + requestOptions: null); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Upsert_WithNullRequestOptions_ShouldSucceed() + { + var response = await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "null-opts-2", PartitionKey = "pk1", Name = "Created" }, + new PartitionKey("pk1"), + requestOptions: null); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Delete_WithNullRequestOptions_ShouldSucceed() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "null-opts-3", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var response = await _cosmos.Container.DeleteItemAsync( + "null-opts-3", new PartitionKey("pk1"), + requestOptions: null); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 19. IfMatchEtag with RequestOptions where IfMatchEtag is null + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Replace_WithRequestOptions_ButNullIfMatchEtag_ShouldSucceed() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "nulletag-1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var response = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "nulletag-1", PartitionKey = "pk1", Name = "Updated" }, + "nulletag-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = null }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 20. Mixed IfMatch + IfNoneMatch (unusual but possible) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Patch_WithBothIfMatchAndIfNoneMatch_MatchingETag_SucceedsThroughPipeline() + { + // Through the SDK pipeline, the Patch handler only propagates IfMatch headers, + // not IfNoneMatch. So IfNoneMatchEtag is effectively ignored for Patch operations. + // Since IfMatch matches the current ETag, the operation succeeds. + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "both-1", PartitionKey = "pk1", Name = "Test", Value = 1 }, + new PartitionKey("pk1")); + + var response = await _cosmos.Container.PatchItemAsync( + "both-1", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Patched") }, + new PatchItemRequestOptions + { + IfMatchEtag = create.ETag, + IfNoneMatchEtag = create.ETag + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 21. Replace Item that was replaced by another operation - chain of ETags + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ETag_Chain_ReplaceReplace_ShouldTrackCorrectly() + { + var v1 = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "chain-1", PartitionKey = "pk1", Name = "v1" }, + new PartitionKey("pk1")); + + var v2 = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "chain-1", PartitionKey = "pk1", Name = "v2" }, + "chain-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = v1.ETag }); + + var v3 = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "chain-1", PartitionKey = "pk1", Name = "v3" }, + "chain-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = v2.ETag }); + + v3.StatusCode.Should().Be(HttpStatusCode.OK); + + // v1 ETag should not work anymore + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "chain-1", PartitionKey = "pk1", Name = "v4 with v1 etag" }, + "chain-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = v1.ETag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + + // v2 ETag should not work anymore either + var act2 = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "chain-1", PartitionKey = "pk1", Name = "v4 with v2 etag" }, + "chain-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = v2.ETag }); + + var ex2 = await act2.Should().ThrowAsync(); + ex2.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + + // v3 ETag should work + var v4 = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "chain-1", PartitionKey = "pk1", Name = "v4" }, + "chain-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = v3.ETag }); + + v4.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 22. ETag after Restore to Point in Time + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task RestoreToPointInTime_RegeneratesETags_OldETagsShouldNotWork() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "pitr-1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var originalEtag = create.ETag; + + // Wait briefly so timestamp differs + await Task.Delay(10); + + // Restore to the current point in time + _cosmos.RestoreToPointInTime(DateTimeOffset.UtcNow); + + // Old ETag should NOT work after PITR (etags are regenerated) + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "pitr-1", PartitionKey = "pk1", Name = "After PITR" }, + "pitr-1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = originalEtag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task RestoreToPointInTime_NewETags_ShouldWork() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "pitr-2", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + await Task.Delay(10); + _cosmos.RestoreToPointInTime(DateTimeOffset.UtcNow); + + // Read to get new ETag + var read = await _cosmos.Container.ReadItemAsync("pitr-2", new PartitionKey("pk1")); + + // New ETag should work + var replace = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "pitr-2", PartitionKey = "pk1", Name = "After PITR" }, + "pitr-2", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = read.ETag }); + + replace.StatusCode.Should().Be(HttpStatusCode.OK); + } +} diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ETagTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/ETagTests.cs similarity index 52% rename from tests/CosmosDB.InMemoryEmulator.Tests.Unit/ETagTests.cs rename to tests/CosmosDB.InMemoryEmulator.Tests.Integration/ETagTests.cs index 8004c41..f998460 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ETagTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/ETagTests.cs @@ -8,17 +8,19 @@ namespace CosmosDB.InMemoryEmulator.Tests; -public class ETagGapTests2 +public class ETagGapTests2 : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("test-container", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); [Fact] public async Task IfMatch_WithWildcard_Star_AlwaysSucceeds() { var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); + await _cosmos.Container.CreateItemAsync(item, new PartitionKey("pk1")); - var response = await _container.UpsertItemAsync( + var response = await _cosmos.Container.UpsertItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, new PartitionKey("pk1"), new ItemRequestOptions { IfMatchEtag = "*" }); @@ -30,9 +32,9 @@ public async Task IfMatch_WithWildcard_Star_AlwaysSucceeds() public async Task IfNoneMatch_WithWildcard_Star_Returns304WhenExists() { var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); + await _cosmos.Container.CreateItemAsync(item, new PartitionKey("pk1")); - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1"), + var act = () => _cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1"), new ItemRequestOptions { IfNoneMatchEtag = "*" }); var ex = await act.Should().ThrowAsync(); @@ -41,18 +43,20 @@ public async Task IfNoneMatch_WithWildcard_Star_Returns304WhenExists() } -public class ETagGapTests +public class ETagGapTests : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("test-container", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); [Fact] public async Task ETag_ChangesOnEveryWrite() { var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }; - var create = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var create = await _cosmos.Container.CreateItemAsync(item, new PartitionKey("pk1")); var firstEtag = create.ETag; - var upsert = await _container.UpsertItemAsync( + var upsert = await _cosmos.Container.UpsertItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second" }, new PartitionKey("pk1")); var secondEtag = upsert.ETag; @@ -64,10 +68,10 @@ public async Task ETag_ChangesOnEveryWrite() public async Task ETag_ConsistentAcrossMultipleReads() { var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); + await _cosmos.Container.CreateItemAsync(item, new PartitionKey("pk1")); - var read1 = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var read2 = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var read1 = await _cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1")); + var read2 = await _cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1")); read1.ETag.Should().Be(read2.ETag); } @@ -76,15 +80,15 @@ public async Task ETag_ConsistentAcrossMultipleReads() public async Task ConcurrentUpsert_IfMatch_SecondWriteFails() { var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - var create = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var create = await _cosmos.Container.CreateItemAsync(item, new PartitionKey("pk1")); var etag = create.ETag; - await _container.UpsertItemAsync( + await _cosmos.Container.UpsertItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First Writer" }, new PartitionKey("pk1"), new ItemRequestOptions { IfMatchEtag = etag }); - var act = () => _container.UpsertItemAsync( + var act = () => _cosmos.Container.UpsertItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second Writer" }, new PartitionKey("pk1"), new ItemRequestOptions { IfMatchEtag = etag }); @@ -95,9 +99,11 @@ await _container.UpsertItemAsync( } -public class ETagGapTests3 +public class ETagGapTests3 : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("test-container", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); [Fact] public async Task IfMatch_OnCreate_IsIgnored() @@ -105,7 +111,7 @@ public async Task IfMatch_OnCreate_IsIgnored() // Create doesn't have a prior version, so IfMatch should be irrelevant var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1"), + var response = await _cosmos.Container.CreateItemAsync(item, new PartitionKey("pk1"), new ItemRequestOptions { IfMatchEtag = "\"nonexistent\"" }); response.StatusCode.Should().Be(HttpStatusCode.Created); @@ -115,9 +121,9 @@ public async Task IfMatch_OnCreate_IsIgnored() public async Task IfMatch_OnPatch_WithCorrectETag_Succeeds() { var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }; - var create = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var create = await _cosmos.Container.CreateItemAsync(item, new PartitionKey("pk1")); - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + var response = await _cosmos.Container.PatchItemAsync("1", new PartitionKey("pk1"), [PatchOperation.Set("/name", "Patched")], new PatchItemRequestOptions { IfMatchEtag = create.ETag }); @@ -129,9 +135,9 @@ public async Task IfMatch_OnPatch_WithCorrectETag_Succeeds() public async Task IfMatch_OnPatch_WithStaleETag_Fails412() { var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); + await _cosmos.Container.CreateItemAsync(item, new PartitionKey("pk1")); - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + var act = () => _cosmos.Container.PatchItemAsync("1", new PartitionKey("pk1"), [PatchOperation.Set("/name", "Patched")], new PatchItemRequestOptions { IfMatchEtag = "\"stale\"" }); @@ -142,18 +148,20 @@ public async Task IfMatch_OnPatch_WithStaleETag_Fails412() #region ETag Response Tests -public class ETagResponseTests +public class ETagResponseTests : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("test-container", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); [Fact] public async Task Delete_TypedResponse_ETag_ShouldBeNull() { - await _container.CreateItemAsync( + await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, new PartitionKey("pk1")); - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + var response = await _cosmos.Container.DeleteItemAsync("1", new PartitionKey("pk1")); response.ETag.Should().BeNull(); } @@ -161,12 +169,12 @@ await _container.CreateItemAsync( [Fact] public async Task Replace_ResponseETag_ChangesFromCreate() { - var create = await _container.CreateItemAsync( + var create = await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, new PartitionKey("pk1")); var createETag = create.ETag; - var replace = await _container.ReplaceItemAsync( + var replace = await _cosmos.Container.ReplaceItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, "1", new PartitionKey("pk1")); @@ -177,12 +185,12 @@ public async Task Replace_ResponseETag_ChangesFromCreate() [Fact] public async Task Patch_ResponseETag_ChangesFromCreate() { - var create = await _container.CreateItemAsync( + var create = await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 1 }, new PartitionKey("pk1")); var createETag = create.ETag; - var patch = await _container.PatchItemAsync( + var patch = await _cosmos.Container.PatchItemAsync( "1", new PartitionKey("pk1"), [PatchOperation.Set("/name", "Patched")]); @@ -195,22 +203,22 @@ public async Task ETag_Format_IsQuotedHex_OnAllWriteOperations() { var hexPattern = "^\"[0-9a-f]{16}\"$"; - var create = await _container.CreateItemAsync( + var create = await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 1 }, new PartitionKey("pk1")); create.ETag.Should().MatchRegex(hexPattern); - var upsert = await _container.UpsertItemAsync( + var upsert = await _cosmos.Container.UpsertItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Upserted" }, new PartitionKey("pk1")); upsert.ETag.Should().MatchRegex(hexPattern); - var replace = await _container.ReplaceItemAsync( + var replace = await _cosmos.Container.ReplaceItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, "1", new PartitionKey("pk1")); replace.ETag.Should().MatchRegex(hexPattern); - var patch = await _container.PatchItemAsync( + var patch = await _cosmos.Container.PatchItemAsync( "1", new PartitionKey("pk1"), [PatchOperation.Set("/name", "Patched")]); patch.ETag.Should().MatchRegex(hexPattern); @@ -219,11 +227,11 @@ public async Task ETag_Format_IsQuotedHex_OnAllWriteOperations() [Fact] public async Task DocumentBody_ETag_MatchesResponseETag() { - var create = await _container.CreateItemAsync( + var create = await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, new PartitionKey("pk1")); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var read = await _cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1")); var bodyETag = read.Resource["_etag"]?.ToString(); bodyETag.Should().Be(create.ETag); @@ -232,18 +240,18 @@ public async Task DocumentBody_ETag_MatchesResponseETag() [Fact] public async Task DocumentBody_ETag_UpdatesOnEveryWrite() { - await _container.CreateItemAsync( + await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, new PartitionKey("pk1")); - var read1 = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var read1 = await _cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1")); var etag1 = read1.Resource["_etag"]?.ToString(); - await _container.UpsertItemAsync( + await _cosmos.Container.UpsertItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second" }, new PartitionKey("pk1")); - var read2 = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var read2 = await _cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1")); var etag2 = read2.Resource["_etag"]?.ToString(); etag1.Should().NotBe(etag2); @@ -254,18 +262,20 @@ await _container.UpsertItemAsync( #region IfMatch Wildcard Tests -public class ETagIfMatchWildcardTests +public class ETagIfMatchWildcardTests : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("test-container", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); [Fact] public async Task IfMatch_Wildcard_OnReplace_Succeeds() { - await _container.CreateItemAsync( + await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, new PartitionKey("pk1")); - var response = await _container.ReplaceItemAsync( + var response = await _cosmos.Container.ReplaceItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, "1", new PartitionKey("pk1"), new ItemRequestOptions { IfMatchEtag = "*" }); @@ -276,11 +286,11 @@ await _container.CreateItemAsync( [Fact] public async Task IfMatch_Wildcard_OnDelete_Succeeds() { - await _container.CreateItemAsync( + await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, new PartitionKey("pk1")); - var response = await _container.DeleteItemAsync( + var response = await _cosmos.Container.DeleteItemAsync( "1", new PartitionKey("pk1"), new ItemRequestOptions { IfMatchEtag = "*" }); @@ -290,11 +300,11 @@ await _container.CreateItemAsync( [Fact] public async Task IfMatch_Wildcard_OnPatch_Succeeds() { - await _container.CreateItemAsync( + await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 1 }, new PartitionKey("pk1")); - var response = await _container.PatchItemAsync( + var response = await _cosmos.Container.PatchItemAsync( "1", new PartitionKey("pk1"), [PatchOperation.Set("/name", "Patched")], new PatchItemRequestOptions { IfMatchEtag = "*" }); @@ -307,14 +317,16 @@ await _container.CreateItemAsync( #region IfNoneMatch Edge Cases -public class ETagIfNoneMatchEdgeCaseTests +public class ETagIfNoneMatchEdgeCaseTests : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("test-container", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); [Fact] public async Task IfNoneMatch_Wildcard_OnRead_WhenItemDoesNotExist_Returns404() { - var act = () => _container.ReadItemAsync("nonexistent", new PartitionKey("pk1"), + var act = () => _cosmos.Container.ReadItemAsync("nonexistent", new PartitionKey("pk1"), new ItemRequestOptions { IfNoneMatchEtag = "*" }); var ex = await act.Should().ThrowAsync(); @@ -324,16 +336,16 @@ public async Task IfNoneMatch_Wildcard_OnRead_WhenItemDoesNotExist_Returns404() [Fact] public async Task IfNoneMatch_StaleETag_OnRead_Returns200() { - var create = await _container.CreateItemAsync( + var create = await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, new PartitionKey("pk1")); var oldETag = create.ETag; - await _container.UpsertItemAsync( + await _cosmos.Container.UpsertItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second" }, new PartitionKey("pk1")); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1"), + var read = await _cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1"), new ItemRequestOptions { IfNoneMatchEtag = oldETag }); read.StatusCode.Should().Be(HttpStatusCode.OK); @@ -343,12 +355,12 @@ await _container.UpsertItemAsync( [Fact] public async Task IfNoneMatch_OnUpsert_IsIgnored() { - await _container.CreateItemAsync( + await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, new PartitionKey("pk1")); // Real Cosmos ignores IfNoneMatch on write operations - var response = await _container.UpsertItemAsync( + var response = await _cosmos.Container.UpsertItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, new PartitionKey("pk1"), new ItemRequestOptions { IfNoneMatchEtag = "*" }); @@ -359,11 +371,11 @@ await _container.CreateItemAsync( [Fact] public async Task IfNoneMatch_OnReplace_IsIgnored() { - await _container.CreateItemAsync( + await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, new PartitionKey("pk1")); - var response = await _container.ReplaceItemAsync( + var response = await _cosmos.Container.ReplaceItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, "1", new PartitionKey("pk1"), new ItemRequestOptions { IfNoneMatchEtag = "*" }); @@ -376,21 +388,23 @@ await _container.CreateItemAsync( #region ETag Lifecycle Tests -public class ETagLifecycleTests +public class ETagLifecycleTests : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("test-container", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); [Fact] public async Task CreateDeleteRecreate_GetsNewETag() { - var create1 = await _container.CreateItemAsync( + var create1 = await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, new PartitionKey("pk1")); var etag1 = create1.ETag; - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + await _cosmos.Container.DeleteItemAsync("1", new PartitionKey("pk1")); - var create2 = await _container.CreateItemAsync( + var create2 = await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second" }, new PartitionKey("pk1")); var etag2 = create2.ETag; @@ -403,7 +417,7 @@ public async Task Upsert_WithIfMatch_WhenItemDoesNotExist_CreatesItem() { // If-Match is "applicable only on PUT and DELETE" per REST API docs. // Upsert uses POST, so If-Match is ignored on the insert path. - var response = await _container.UpsertItemAsync( + var response = await _cosmos.Container.UpsertItemAsync( new TestDocument { Id = "new", PartitionKey = "pk1", Name = "Test" }, new PartitionKey("pk1"), new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); @@ -414,7 +428,7 @@ public async Task Upsert_WithIfMatch_WhenItemDoesNotExist_CreatesItem() [Fact] public async Task Delete_WithIfMatch_NonExistentItem_Returns404_Not412() { - var act = () => _container.DeleteItemAsync( + var act = () => _cosmos.Container.DeleteItemAsync( "nonexistent", new PartitionKey("pk1"), new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); @@ -425,7 +439,7 @@ public async Task Delete_WithIfMatch_NonExistentItem_Returns404_Not412() [Fact] public async Task Replace_WithIfMatch_NonExistentItem_Returns404_Not412() { - var act = () => _container.ReplaceItemAsync( + var act = () => _cosmos.Container.ReplaceItemAsync( new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "Test" }, "nonexistent", new PartitionKey("pk1"), new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); @@ -439,20 +453,22 @@ public async Task Replace_WithIfMatch_NonExistentItem_Returns404_Not412() #region ETag Stream Tests -public class ETagStreamTests +public class ETagStreamTests : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("test-container", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); [Fact] public async Task StreamRead_IfNoneMatch_Wildcard_WhenExists_Returns304() { - await _container.CreateItemStreamAsync( + await _cosmos.Container.CreateItemStreamAsync( ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Test\"}"), new PartitionKey("pk1")); - var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1"), + var response = await _cosmos.Container.ReadItemStreamAsync("1", new PartitionKey("pk1"), new ItemRequestOptions { IfNoneMatchEtag = "*" }); response.StatusCode.Should().Be(HttpStatusCode.NotModified); @@ -461,16 +477,16 @@ await _container.CreateItemStreamAsync( [Fact] public async Task StreamRead_IfNoneMatch_StaleETag_Returns200() { - var createResp = await _container.CreateItemStreamAsync( + var createResp = await _cosmos.Container.CreateItemStreamAsync( ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"First\"}"), new PartitionKey("pk1")); var oldETag = createResp.Headers["ETag"]; - await _container.UpsertItemStreamAsync( + await _cosmos.Container.UpsertItemStreamAsync( ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Second\"}"), new PartitionKey("pk1")); - var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1"), + var response = await _cosmos.Container.ReadItemStreamAsync("1", new PartitionKey("pk1"), new ItemRequestOptions { IfNoneMatchEtag = oldETag }); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -479,11 +495,11 @@ await _container.UpsertItemStreamAsync( [Fact] public async Task StreamReplace_IfMatch_Wildcard_Succeeds() { - await _container.CreateItemStreamAsync( + await _cosmos.Container.CreateItemStreamAsync( ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Original\"}"), new PartitionKey("pk1")); - var response = await _container.ReplaceItemStreamAsync( + var response = await _cosmos.Container.ReplaceItemStreamAsync( ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Replaced\"}"), "1", new PartitionKey("pk1"), new ItemRequestOptions { IfMatchEtag = "*" }); @@ -494,11 +510,11 @@ await _container.CreateItemStreamAsync( [Fact] public async Task StreamDelete_IfMatch_Wildcard_Succeeds() { - await _container.CreateItemStreamAsync( + await _cosmos.Container.CreateItemStreamAsync( ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Test\"}"), new PartitionKey("pk1")); - var response = await _container.DeleteItemStreamAsync( + var response = await _cosmos.Container.DeleteItemStreamAsync( "1", new PartitionKey("pk1"), new ItemRequestOptions { IfMatchEtag = "*" }); @@ -508,12 +524,12 @@ await _container.CreateItemStreamAsync( [Fact] public async Task StreamPatch_WithCurrentETag_Succeeds() { - var createResp = await _container.CreateItemStreamAsync( + var createResp = await _cosmos.Container.CreateItemStreamAsync( ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Test\"}"), new PartitionKey("pk1")); var currentETag = createResp.Headers["ETag"]; - var response = await _container.PatchItemStreamAsync( + var response = await _cosmos.Container.PatchItemStreamAsync( "1", new PartitionKey("pk1"), [PatchOperation.Set("/name", "Patched")], new PatchItemRequestOptions { IfMatchEtag = currentETag }); @@ -524,11 +540,11 @@ public async Task StreamPatch_WithCurrentETag_Succeeds() [Fact] public async Task StreamDelete_Response_HasNoETagHeader() { - await _container.CreateItemStreamAsync( + await _cosmos.Container.CreateItemStreamAsync( ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Test\"}"), new PartitionKey("pk1")); - var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); + var response = await _cosmos.Container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); response.Headers["ETag"].Should().BeNull(); } @@ -538,25 +554,27 @@ await _container.CreateItemStreamAsync( #region ETag Batch Tests -public class ETagBatchStreamTests +public class ETagBatchStreamTests : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("test-container", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); [Fact] public async Task BatchStream_Replace_WithStaleETag_FailsBatch() { - var create = await _container.CreateItemAsync( + var create = await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, new PartitionKey("pk1")); var oldETag = create.ETag; - await _container.UpsertItemAsync( + await _cosmos.Container.UpsertItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Changed" }, new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + var batch = _cosmos.Container.CreateTransactionalBatch(new PartitionKey("pk1")); batch.ReplaceItemStream("1", ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"BatchReplaced\"}"), new TransactionalBatchItemRequestOptions { IfMatchEtag = oldETag }); @@ -569,16 +587,16 @@ await _container.UpsertItemAsync( [Fact] public async Task BatchStream_Upsert_WithStaleETag_FailsBatch() { - var create = await _container.CreateItemAsync( + var create = await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, new PartitionKey("pk1")); var oldETag = create.ETag; - await _container.UpsertItemAsync( + await _cosmos.Container.UpsertItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Changed" }, new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + var batch = _cosmos.Container.CreateTransactionalBatch(new PartitionKey("pk1")); batch.UpsertItemStream( ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"BatchUpserted\"}"), new TransactionalBatchItemRequestOptions { IfMatchEtag = oldETag }); @@ -591,16 +609,16 @@ await _container.UpsertItemAsync( [Fact] public async Task Batch_Delete_WithIfMatch_StaleETag_FailsBatch() { - var create = await _container.CreateItemAsync( + var create = await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, new PartitionKey("pk1")); var oldETag = create.ETag; - await _container.UpsertItemAsync( + await _cosmos.Container.UpsertItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Changed" }, new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + var batch = _cosmos.Container.CreateTransactionalBatch(new PartitionKey("pk1")); batch.DeleteItem("1", new TransactionalBatchItemRequestOptions { IfMatchEtag = oldETag }); using var response = await batch.ExecuteAsync(); @@ -611,18 +629,18 @@ await _container.UpsertItemAsync( [Fact] public async Task Batch_Delete_WithIfMatch_CurrentETag_Succeeds() { - var create = await _container.CreateItemAsync( + var create = await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + var batch = _cosmos.Container.CreateTransactionalBatch(new PartitionKey("pk1")); batch.DeleteItem("1", new TransactionalBatchItemRequestOptions { IfMatchEtag = create.ETag }); using var response = await batch.ExecuteAsync(); response.IsSuccessStatusCode.Should().BeTrue(); // Confirm item is deleted - var readAct = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); + var readAct = () => _cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1")); var ex = await readAct.Should().ThrowAsync(); ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); } @@ -632,18 +650,20 @@ public async Task Batch_Delete_WithIfMatch_CurrentETag_Succeeds() #region GAP-4: Stream Read with Current Specific ETag -public class ETagStreamReadCurrentTests +public class ETagStreamReadCurrentTests : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("test-container", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); [Fact] public async Task StreamRead_IfNoneMatch_CurrentETag_Returns304() { - var create = await _container.CreateItemAsync( + var create = await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, new PartitionKey("pk1")); - var response = await _container.ReadItemStreamAsync( + var response = await _cosmos.Container.ReadItemStreamAsync( "1", new PartitionKey("pk1"), new ItemRequestOptions { IfNoneMatchEtag = create.ETag }); @@ -655,52 +675,54 @@ public async Task StreamRead_IfNoneMatch_CurrentETag_Returns304() #region GAP-6: Body _etag After Various Operations -public class ETagBodyMatchTests +public class ETagBodyMatchTests : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("test-container", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); [Fact] public async Task DocumentBody_ETag_MatchesResponseETag_AfterUpsert() { - await _container.CreateItemAsync( + await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, new PartitionKey("pk1")); - var upsertResponse = await _container.UpsertItemAsync( + var upsertResponse = await _cosmos.Container.UpsertItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, new PartitionKey("pk1")); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var read = await _cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1")); read.Resource["_etag"]!.Value().Should().Be(upsertResponse.ETag); } [Fact] public async Task DocumentBody_ETag_MatchesResponseETag_AfterReplace() { - await _container.CreateItemAsync( + await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, new PartitionKey("pk1")); - var replaceResponse = await _container.ReplaceItemAsync( + var replaceResponse = await _cosmos.Container.ReplaceItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, "1", new PartitionKey("pk1")); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var read = await _cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1")); read.Resource["_etag"]!.Value().Should().Be(replaceResponse.ETag); } [Fact] public async Task DocumentBody_ETag_MatchesResponseETag_AfterPatch() { - await _container.CreateItemAsync( + await _cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, new PartitionKey("pk1")); - var patchResponse = await _container.PatchItemAsync( + var patchResponse = await _cosmos.Container.PatchItemAsync( "1", new PartitionKey("pk1"), new[] { PatchOperation.Set("/name", "Patched") }); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var read = await _cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1")); read.Resource["_etag"]!.Value().Should().Be(patchResponse.ETag); } } @@ -716,9 +738,9 @@ public async Task IfMatch_Wildcard_OnUpsert_WhenItemDoesNotExist_CreatesItem() { // If-Match is "applicable only on PUT and DELETE" per REST API docs. // Upsert uses POST, so If-Match (including wildcard) is ignored on the insert path. - var container = new InMemoryContainer("test", "/partitionKey"); + using var cosmos = InMemoryCosmos.Create("test", "/partitionKey"); - var response = await container.UpsertItemAsync( + var response = await cosmos.Container.UpsertItemAsync( new TestDocument { Id = "new", PartitionKey = "pk1", Name = "New" }, new PartitionKey("pk1"), new ItemRequestOptions { IfMatchEtag = "*" }); @@ -736,12 +758,12 @@ public class ETagIfNoneMatchCreateTests [Fact] public async Task IfNoneMatch_Wildcard_OnCreate_WhenItemAlreadyExists_Returns409() { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( + using var cosmos = InMemoryCosmos.Create("test", "/partitionKey"); + await cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Existing" }, new PartitionKey("pk1")); - var act = () => container.CreateItemAsync( + var act = () => cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }, new PartitionKey("pk1"), new ItemRequestOptions { IfNoneMatchEtag = "*" }); @@ -755,15 +777,17 @@ await container.CreateItemAsync( #region GAP-9: Stream Create ETag Header -public class ETagStreamCreateTests +public class ETagStreamCreateTests : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("test-container", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); [Fact] public async Task StreamCreate_Response_HasETagHeader() { var json = "{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"test\"}"; - var response = await _container.CreateItemStreamAsync( + var response = await _cosmos.Container.CreateItemStreamAsync( new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); response.Headers["ETag"].Should().NotBeNullOrEmpty(); @@ -773,11 +797,11 @@ public async Task StreamCreate_Response_HasETagHeader() public async Task StreamCreate_ResponseETag_ChangesFromPriorItem() { var json1 = "{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"first\"}"; - var response1 = await _container.CreateItemStreamAsync( + var response1 = await _cosmos.Container.CreateItemStreamAsync( new MemoryStream(Encoding.UTF8.GetBytes(json1)), new PartitionKey("pk1")); var json2 = "{\"id\":\"2\",\"partitionKey\":\"pk1\",\"name\":\"second\"}"; - var response2 = await _container.CreateItemStreamAsync( + var response2 = await _cosmos.Container.CreateItemStreamAsync( new MemoryStream(Encoding.UTF8.GetBytes(json2)), new PartitionKey("pk1")); response2.Headers["ETag"].Should().NotBe(response1.Headers["ETag"]); @@ -793,15 +817,15 @@ public class ETagQueryTests [Fact] public async Task ETag_InSqlQueryResults() { - var container = new InMemoryContainer("test", "/partitionKey"); - var create1 = await container.CreateItemAsync( + using var cosmos = InMemoryCosmos.Create("test", "/partitionKey"); + var create1 = await cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - var create2 = await container.CreateItemAsync( + var create2 = await cosmos.Container.CreateItemAsync( new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); - var query = container.GetItemQueryIterator( + var query = cosmos.Container.GetItemQueryIterator( new QueryDefinition("SELECT c.id, c._etag FROM c ORDER BY c.id")); var results = new List(); while (query.HasMoreResults) @@ -822,17 +846,17 @@ public class ETagPersistenceTests [Fact] public async Task ETag_PreservedThroughExportImport() { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( + using var cosmos = InMemoryCosmos.Create("test", "/partitionKey"); + await cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - var state = container.ExportState(); - container.ClearItems(); + var state = cosmos.ExportState(); + cosmos.ClearItems(); - container.ImportState(state); + cosmos.ImportState(state); - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var read = await cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1")); // After import, body _etag exists but may differ from original (re-enrichment) read.Resource["_etag"]!.Value().Should().NotBeNullOrEmpty(); } @@ -847,29 +871,29 @@ public class ETagPatchAllTypesTests [Fact] public async Task Patch_AllOperationTypes_GenerateNewETag() { - var container = new InMemoryContainer("test", "/partitionKey"); - var create = await container.CreateItemAsync( + using var cosmos = InMemoryCosmos.Create("test", "/partitionKey"); + var create = await cosmos.Container.CreateItemAsync( JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Test", value = 10, tags = new[] { "a" } }), new PartitionKey("pk1")); var etags = new List { create.ETag }; - var r1 = await container.PatchItemAsync("1", new PartitionKey("pk1"), + var r1 = await cosmos.Container.PatchItemAsync("1", new PartitionKey("pk1"), new[] { PatchOperation.Set("/name", "Updated") }); etags.Add(r1.ETag); - var r2 = await container.PatchItemAsync("1", new PartitionKey("pk1"), + var r2 = await cosmos.Container.PatchItemAsync("1", new PartitionKey("pk1"), new[] { PatchOperation.Increment("/value", 1) }); etags.Add(r2.ETag); - var r3 = await container.PatchItemAsync("1", new PartitionKey("pk1"), + var r3 = await cosmos.Container.PatchItemAsync("1", new PartitionKey("pk1"), new[] { PatchOperation.Add("/tags/-", "b") }); etags.Add(r3.ETag); - var r4 = await container.PatchItemAsync("1", new PartitionKey("pk1"), + var r4 = await cosmos.Container.PatchItemAsync("1", new PartitionKey("pk1"), new[] { PatchOperation.Remove("/tags/0") }); etags.Add(r4.ETag); - var r5 = await container.PatchItemAsync("1", new PartitionKey("pk1"), + var r5 = await cosmos.Container.PatchItemAsync("1", new PartitionKey("pk1"), new[] { PatchOperation.Replace("/name", "Final") }); etags.Add(r5.ETag); @@ -881,14 +905,16 @@ public async Task Patch_AllOperationTypes_GenerateNewETag() #region GAP-14/15: Stream Delete/Replace IfMatch Non-Existent -public class ETagStreamNonExistentTests +public class ETagStreamNonExistentTests : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("test-container", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); [Fact] public async Task StreamDelete_IfMatch_NonExistentItem_Returns404() { - var response = await _container.DeleteItemStreamAsync( + var response = await _cosmos.Container.DeleteItemStreamAsync( "missing", new PartitionKey("pk1"), new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); @@ -899,7 +925,7 @@ public async Task StreamDelete_IfMatch_NonExistentItem_Returns404() public async Task StreamReplace_IfMatch_NonExistentItem_Returns404() { var json = "{\"id\":\"missing\",\"partitionKey\":\"pk1\",\"name\":\"test\"}"; - var response = await _container.ReplaceItemStreamAsync( + var response = await _cosmos.Container.ReplaceItemStreamAsync( new MemoryStream(Encoding.UTF8.GetBytes(json)), "missing", new PartitionKey("pk1"), new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); @@ -917,15 +943,15 @@ public class ETagRapidWriteTests [Fact] public async Task MultipleRapidWrites_EachGetsUniqueETag() { - var container = new InMemoryContainer("test", "/partitionKey"); - var create = await container.CreateItemAsync( + using var cosmos = InMemoryCosmos.Create("test", "/partitionKey"); + var create = await cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V0" }, new PartitionKey("pk1")); var etags = new List { create.ETag }; for (var i = 1; i <= 10; i++) { - var r = await container.UpsertItemAsync( + var r = await cosmos.Container.UpsertItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, new PartitionKey("pk1")); etags.Add(r.ETag); @@ -945,12 +971,12 @@ public class ETagBatchReadTests [Fact] public async Task Batch_ReadItem_Response_HasEmptyETag() { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( + using var cosmos = InMemoryCosmos.Create("test", "/partitionKey"); + await cosmos.Container.CreateItemAsync( new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, new PartitionKey("pk1")); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + var batch = cosmos.Container.CreateTransactionalBatch(new PartitionKey("pk1")); batch.ReadItem("1"); using var response = await batch.ExecuteAsync(); @@ -961,3 +987,282 @@ await container.CreateItemAsync( } #endregion + +#region Issue-24: IfMatchEtag Precondition Enforcement + +/// +/// Regression tests for GitHub issue #24: +/// ReplaceItemAsync and UpsertItemAsync must enforce IfMatchEtag preconditions. +/// When IfMatchEtag doesn't match the document's current _etag, the emulator should +/// throw CosmosException with HttpStatusCode.PreconditionFailed (412). +/// +public class ETagIfMatchPreconditionTests : IDisposable +{ + private readonly InMemoryCosmosResult _cosmos = InMemoryCosmos.Create("test-container", "/partitionKey"); + + public void Dispose() => _cosmos.Dispose(); + + [Fact] + public async Task ReplaceItemAsync_WithStaleETag_Throws412() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + var create = await _cosmos.Container.CreateItemAsync(item, new PartitionKey("pk1")); + var originalEtag = create.ETag; + + // Concurrent update invalidates the ETag + await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Concurrent update" }, + "1", new PartitionKey("pk1")); + + // Replace with stale ETag should throw 412 + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Should fail" }, + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = originalEtag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task ReplaceItemAsync_WithCurrentETag_Succeeds() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + var create = await _cosmos.Container.CreateItemAsync(item, new PartitionKey("pk1")); + + var response = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = create.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task UpsertItemAsync_WithStaleETag_Throws412() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + var create = await _cosmos.Container.CreateItemAsync(item, new PartitionKey("pk1")); + var originalEtag = create.ETag; + + // Concurrent update invalidates the ETag + await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Concurrent update" }, + new PartitionKey("pk1")); + + // Upsert with stale ETag should throw 412 + var act = () => _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Should fail" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = originalEtag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task UpsertItemAsync_WithCurrentETag_Succeeds() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + var create = await _cosmos.Container.CreateItemAsync(item, new PartitionKey("pk1")); + + var response = await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = create.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteItemAsync_WithStaleETag_Throws412() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + var create = await _cosmos.Container.CreateItemAsync(item, new PartitionKey("pk1")); + var originalEtag = create.ETag; + + // Update to change the ETag + await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + "1", new PartitionKey("pk1")); + + // Delete with stale ETag should throw 412 + var act = () => _cosmos.Container.DeleteItemAsync( + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = originalEtag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task ReplaceItemAsync_WithBogusETag_Throws412() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Should fail" }, + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"completely-bogus\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task UpsertItemAsync_WithBogusETag_Throws412() + { + await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var act = () => _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Should fail" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"completely-bogus\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task StreamReplace_WithStaleETag_Returns412() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var originalEtag = create.ETag; + + // Concurrent update + await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + "1", new PartitionKey("pk1")); + + // Stream replace with stale ETag + using var stream = new MemoryStream(Encoding.UTF8.GetBytes( + "{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Should fail\"}")); + var response = await _cosmos.Container.ReplaceItemStreamAsync( + stream, "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = originalEtag }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task StreamUpsert_WithStaleETag_Returns412() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var originalEtag = create.ETag; + + // Concurrent update + await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + + // Stream upsert with stale ETag + using var stream = new MemoryStream(Encoding.UTF8.GetBytes( + "{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Should fail\"}")); + var response = await _cosmos.Container.UpsertItemStreamAsync( + stream, new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = originalEtag }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task StreamDelete_WithStaleETag_Returns412() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var originalEtag = create.ETag; + + // Concurrent update + await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + "1", new PartitionKey("pk1")); + + var response = await _cosmos.Container.DeleteItemStreamAsync( + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = originalEtag }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task OptimisticConcurrency_ReplaceReplace_SecondWriterFails() + { + // Exact reproduction from issue #24 + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + var createResponse = await _cosmos.Container.CreateItemAsync(item, new PartitionKey("pk1")); + var validEtag = createResponse.ETag; + + // First writer succeeds + var firstWrite = await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First writer" }, + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = validEtag }); + firstWrite.StatusCode.Should().Be(HttpStatusCode.OK); + + // Second writer with same (now stale) ETag fails + var act = () => _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second writer" }, + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = validEtag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Batch_Replace_WithStaleETag_FailsBatch() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var staleEtag = create.ETag; + + // Update to invalidate ETag + await _cosmos.Container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + "1", new PartitionKey("pk1")); + + var batch = _cosmos.Container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Batch replace" }, + new TransactionalBatchItemRequestOptions { IfMatchEtag = staleEtag }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Batch_Upsert_WithStaleETag_FailsBatch() + { + var create = await _cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var staleEtag = create.ETag; + + // Update to invalidate ETag + await _cosmos.Container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + + var batch = _cosmos.Container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Batch upsert" }, + new TransactionalBatchItemRequestOptions { IfMatchEtag = staleEtag }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } +} + +#endregion