From 52dd503c27170ca8f8374adc2346b11fb65589bd Mon Sep 17 00:00:00 2001 From: Kane Vo Date: Wed, 10 Jun 2026 14:39:20 +0700 Subject: [PATCH 1/4] feat: deserialize etag and pass it to cosmos sdk --- .../Abstractions/DatabaseClientBaseTests.cs | 120 ++++++++++++++++++ .../Fakes/Entities/User.cs | 8 +- .../Abstractions/DatabaseClientBase`1.cs | 14 ++ .../Attributes/ETagAttribute.cs | 8 ++ .../Fakes/IUserWithETagRepository.cs | 11 ++ .../Fakes/UserWithETag.cs | 23 ++++ .../CosmosDatabaseRepositoryTests.cs | 108 ++++++++++++++++ .../Client/CosmosDatabaseClient`1.cs | 117 +++++++++++++---- 8 files changed, 385 insertions(+), 24 deletions(-) create mode 100644 src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Abstractions/DatabaseClientBaseTests.cs create mode 100644 src/core/Wemogy.Infrastructure.Database.Core/Attributes/ETagAttribute.cs create mode 100644 src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Fakes/IUserWithETagRepository.cs create mode 100644 src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Fakes/UserWithETag.cs diff --git a/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Abstractions/DatabaseClientBaseTests.cs b/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Abstractions/DatabaseClientBaseTests.cs new file mode 100644 index 0000000..7a3f3ae --- /dev/null +++ b/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Abstractions/DatabaseClientBaseTests.cs @@ -0,0 +1,120 @@ +using FluentAssertions; +using Wemogy.Infrastructure.Database.Core.Abstractions; +using Wemogy.Infrastructure.Database.Core.Attributes; +using Xunit; + +namespace Wemogy.Infrastructure.Database.Core.UnitTests.Abstractions; + +public class DatabaseClientBaseTests +{ + [Fact] + public void ResolveETagValue_ShouldReturnETagPropertyValue() + { + // Arrange + var client = new TestableDatabaseClient(); + var entity = new EntityWithETag + { + ETag = "etag-123" + }; + + // Act + var eTag = client.ResolveETag(entity); + + // Assert + eTag.Should().Be("etag-123"); + } + + [Fact] + public void SetETagValue_ShouldSetETagProperty() + { + // Arrange + var client = new TestableDatabaseClient(); + var entity = new EntityWithETag(); + + // Act + client.SetETag(entity, "etag-456"); + + // Assert + entity.ETag.Should().Be("etag-456"); + } + + [Fact] + public void SetETagValue_ShouldSupportNull() + { + // Arrange + var client = new TestableDatabaseClient(); + var entity = new EntityWithETag + { + ETag = "etag-123" + }; + + // Act + client.SetETag(entity, null); + + // Assert + entity.ETag.Should().BeNull(); + } + + [Fact] + public void ResolveETagValue_ShouldReturnNullForEntityWithoutETagProperty() + { + // Arrange + var client = new TestableDatabaseClient(); + var entity = new EntityWithoutETag(); + + // Act + var eTag = client.ResolveETag(entity); + + // Assert + eTag.Should().BeNull(); + } + + [Fact] + public void SetETagValue_ShouldBeNoOpForEntityWithoutETagProperty() + { + // Arrange + var client = new TestableDatabaseClient(); + var entity = new EntityWithoutETag(); + + // Act + var exception = Record.Exception(() => client.SetETag(entity, "etag-123")); + + // Assert + exception.Should().BeNull(); + } + + private class EntityWithETag + { + [Id] + public string Id { get; set; } = string.Empty; + + [PartitionKey] + public string TenantId { get; set; } = string.Empty; + + [ETag] + public string? ETag { get; set; } + } + + private class EntityWithoutETag + { + [Id] + public string Id { get; set; } = string.Empty; + + [PartitionKey] + public string TenantId { get; set; } = string.Empty; + } + + private class TestableDatabaseClient : DatabaseClientBase + where TEntity : class + { + public string? ResolveETag(TEntity entity) + { + return ResolveETagValue(entity); + } + + public void SetETag(TEntity entity, string? eTag) + { + SetETagValue(entity, eTag); + } + } +} diff --git a/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Fakes/Entities/User.cs b/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Fakes/Entities/User.cs index a2888af..d66ef98 100644 --- a/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Fakes/Entities/User.cs +++ b/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Fakes/Entities/User.cs @@ -43,7 +43,13 @@ public static Faker Faker f => f.Random.Guid().ToString()) .RuleFor( x => x.Firstname, - f => f.Name.FirstName()) + f => + { + // "John" is silently excluded by GeneralUserReadFilter, + // which makes count/getAll tests flaky + var firstname = f.Name.FirstName(); + return firstname == "John" ? "Jane" : firstname; + }) .RuleFor( x => x.Lastname, f => f.Name.LastName()) diff --git a/src/core/Wemogy.Infrastructure.Database.Core/Abstractions/DatabaseClientBase`1.cs b/src/core/Wemogy.Infrastructure.Database.Core/Abstractions/DatabaseClientBase`1.cs index e6a01bc..7f930aa 100644 --- a/src/core/Wemogy.Infrastructure.Database.Core/Abstractions/DatabaseClientBase`1.cs +++ b/src/core/Wemogy.Infrastructure.Database.Core/Abstractions/DatabaseClientBase`1.cs @@ -10,6 +10,7 @@ public abstract class DatabaseClientBase { private readonly PropertyInfo _partitionKeyPropertyInfo; private readonly PropertyInfo _idPropertyInfo; + private readonly PropertyInfo? _eTagPropertyInfo; protected DatabaseClientBase() { @@ -32,6 +33,9 @@ protected DatabaseClientBase() } _partitionKeyPropertyInfo = partitionKeyPropertyInfo; + + // optional: entities opt into optimistic concurrency with [ETag] + _eTagPropertyInfo = typeof(TEntity).GetPropertyByCustomAttribute(); } protected string ResolveIdValue(TEntity entity) @@ -45,4 +49,14 @@ protected string ResolvePartitionKeyValue(TEntity entity) var partitionKeyValue = (string)_partitionKeyPropertyInfo.GetValue(entity); return partitionKeyValue; } + + protected string? ResolveETagValue(TEntity entity) + { + return (string?)_eTagPropertyInfo?.GetValue(entity); + } + + protected void SetETagValue(TEntity entity, string? eTag) + { + _eTagPropertyInfo?.SetValue(entity, eTag); + } } diff --git a/src/core/Wemogy.Infrastructure.Database.Core/Attributes/ETagAttribute.cs b/src/core/Wemogy.Infrastructure.Database.Core/Attributes/ETagAttribute.cs new file mode 100644 index 0000000..6068c51 --- /dev/null +++ b/src/core/Wemogy.Infrastructure.Database.Core/Attributes/ETagAttribute.cs @@ -0,0 +1,8 @@ +using System; + +namespace Wemogy.Infrastructure.Database.Core.Attributes; + +[AttributeUsage(AttributeTargets.Property)] +public class ETagAttribute : Attribute +{ +} diff --git a/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Fakes/IUserWithETagRepository.cs b/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Fakes/IUserWithETagRepository.cs new file mode 100644 index 0000000..d36de9c --- /dev/null +++ b/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Fakes/IUserWithETagRepository.cs @@ -0,0 +1,11 @@ +using Wemogy.Infrastructure.Database.Core.Abstractions; +using Wemogy.Infrastructure.Database.Core.Attributes; + +namespace Wemogy.Infrastructure.Database.Cosmos.UnitTests.Fakes; + +// reuses the existing "users" container (partition key path /tenantId), +// so no additional container provisioning is required locally or in CI +[RepositoryOptions(collectionName: "users")] +public interface IUserWithETagRepository : IDatabaseRepository +{ +} diff --git a/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Fakes/UserWithETag.cs b/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Fakes/UserWithETag.cs new file mode 100644 index 0000000..bc919e3 --- /dev/null +++ b/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Fakes/UserWithETag.cs @@ -0,0 +1,23 @@ +using Wemogy.Infrastructure.Database.Core.Abstractions; +using Wemogy.Infrastructure.Database.Core.Attributes; + +namespace Wemogy.Infrastructure.Database.Cosmos.UnitTests.Fakes; + +/// +/// Cosmos-only fake that opts into optimistic concurrency via . +/// Kept out of the shared Core.UnitTests fakes on purpose: the eTag value is +/// path-dependent (point operations populate it, queries do not), which breaks +/// the cross-path BeEquivalentTo assertions of the shared repository test suite. +/// +public class UserWithETag : EntityBase +{ + [PartitionKey] + public string TenantId { get; set; } = string.Empty; + + public string Firstname { get; set; } = string.Empty; + + public string Lastname { get; set; } = string.Empty; + + [ETag] + public string? ETag { get; set; } +} diff --git a/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Repositories/CosmosDatabaseRepositoryTests.cs b/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Repositories/CosmosDatabaseRepositoryTests.cs index b613ec5..e0e885b 100644 --- a/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Repositories/CosmosDatabaseRepositoryTests.cs +++ b/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Repositories/CosmosDatabaseRepositoryTests.cs @@ -1,7 +1,14 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Wemogy.Core.Errors.Exceptions; +using Wemogy.Infrastructure.Database.Core.Abstractions; using Wemogy.Infrastructure.Database.Core.UnitTests.DatabaseRepositories; +using Wemogy.Infrastructure.Database.Core.UnitTests.Fakes.Entities; using Wemogy.Infrastructure.Database.Core.UnitTests.Repositories; using Wemogy.Infrastructure.Database.Cosmos.Factories; using Wemogy.Infrastructure.Database.Cosmos.UnitTests.Constants; +using Wemogy.Infrastructure.Database.Cosmos.UnitTests.Fakes; using Xunit; namespace Wemogy.Infrastructure.Database.Cosmos.UnitTests.Repositories; @@ -9,6 +16,8 @@ namespace Wemogy.Infrastructure.Database.Cosmos.UnitTests.Repositories; [Collection("Sequential")] public class CosmosDatabaseRepositoryTests : RepositoryTestBase { + private readonly IDatabaseRepository _userWithETagRepository; + public CosmosDatabaseRepositoryTests() : base( () => CosmosDatabaseRepositoryFactory.CreateInstance( @@ -22,5 +31,104 @@ public CosmosDatabaseRepositoryTests() true, true)) { + _userWithETagRepository = CosmosDatabaseRepositoryFactory.CreateInstance( + TestingConstants.ConnectionString, + TestingConstants.DatabaseName, + true, + true); + } + + [Fact] + public async Task UpdateAsync_ShouldRecoverFromRealETagConflict() + { + // Arrange + var user = NewUserWithETag(); + await _userWithETagRepository.CreateAsync(user); + var concurrentWriteDone = false; + + // Act: a concurrent writer bumps the eTag between this update's Get and Replace. + // 1st attempt: Get (eTag A) -> concurrent update writes (eTag becomes B) + // -> Replace with IfMatch A -> real 412 -> PreconditionFailedErrorException + // RetryProxy re-runs UpdateAsync: Get (eTag B) -> Replace with IfMatch B -> success + var updatedUser = await _userWithETagRepository.UpdateAsync( + user.Id, + user.TenantId, + async u => + { + if (!concurrentWriteDone) + { + concurrentWriteDone = true; + await _userWithETagRepository.UpdateAsync( + user.Id, + user.TenantId, + concurrentUser => concurrentUser.Lastname = "Concurrent"); + } + + u.Firstname = "Updated"; + }); + + // Assert: both writes survived — proves the 412 fired and the retry re-read. + // Without the eTag guard the outer replace would overwrite Lastname. + updatedUser.Firstname.Should().Be("Updated"); + updatedUser.Lastname.Should().Be("Concurrent"); + } + + [Fact] + public async Task ReplaceAsync_ShouldThrowPreconditionFailedForStaleETag() + { + // Arrange + var user = NewUserWithETag(); + await _userWithETagRepository.CreateAsync(user); + var staleUser = await _userWithETagRepository.GetAsync( + user.Id, + user.TenantId); + var freshUser = await _userWithETagRepository.GetAsync( + user.Id, + user.TenantId); + freshUser.Firstname = "Fresh"; + await _userWithETagRepository.ReplaceAsync(freshUser); + + // Act: replace with the instance that still carries the old eTag. + // A direct ReplaceAsync has no re-read, so all RetryProxy attempts hit 412. + staleUser.Firstname = "Stale"; + var exception = await Record.ExceptionAsync( + () => _userWithETagRepository.ReplaceAsync(staleUser)); + + // Assert + exception.Should().BeOfType(); + + // last write must NOT have won + var persistedUser = await _userWithETagRepository.GetAsync( + user.Id, + user.TenantId); + persistedUser.Firstname.Should().Be("Fresh"); + } + + [Fact] + public async Task UpdateAsync_OnEntityWithoutETagProperty_ShouldStillWork() + { + // Arrange: DataCenter has no [ETag] property -> unconditional replace path + await ResetAsync(); + var dataCenter = DataCenter.Faker.Generate(); + await DataCenterRepository.CreateAsync(dataCenter); + + // Act + var updatedDataCenter = await DataCenterRepository.UpdateAsync( + dataCenter.Id, + dataCenter.PartitionKey, + d => d.Location = "Updated"); + + // Assert + updatedDataCenter.Location.Should().Be("Updated"); + } + + private static UserWithETag NewUserWithETag() + { + return new UserWithETag + { + TenantId = Guid.NewGuid().ToString(), + Firstname = "Initial", + Lastname = "Initial" + }; } } diff --git a/src/cosmos/Wemogy.Infrastructure.Database.Cosmos/Client/CosmosDatabaseClient`1.cs b/src/cosmos/Wemogy.Infrastructure.Database.Cosmos/Client/CosmosDatabaseClient`1.cs index ca02510..cc55941 100644 --- a/src/cosmos/Wemogy.Infrastructure.Database.Cosmos/Client/CosmosDatabaseClient`1.cs +++ b/src/cosmos/Wemogy.Infrastructure.Database.Cosmos/Client/CosmosDatabaseClient`1.cs @@ -40,7 +40,9 @@ public async Task GetAsync(string id, string partitionKey, Cancellation new PartitionKey(partitionKey).CosmosPartitionKey, cancellationToken: cancellationToken); - return itemResponse; + var entity = itemResponse.Resource; + SetETagValue(entity, itemResponse.ETag); + return entity; } catch (CosmosException e) { @@ -114,6 +116,12 @@ public async Task CountAsync(Expression> predicate, Ca public async Task CreateAsync(TEntity entity) { var partitionKey = ResolvePartitionKey(entity); + + // never persist an eTag into the document body — queries would + // deserialize a stale value and cause false 412s on later replaces + var eTag = ResolveETagValue(entity); + SetETagValue(entity, null); + try { var createResponse = await _container.CreateItemAsync( @@ -124,10 +132,15 @@ public async Task CreateAsync(TEntity entity) EnableContentResponseOnWrite = true }); - return createResponse.Resource; + var createdEntity = createResponse.Resource; + SetETagValue(createdEntity, createResponse.ETag); + SetETagValue(entity, createResponse.ETag); + return createdEntity; } catch (CosmosException cosmosException) { + SetETagValue(entity, eTag); + if (cosmosException.StatusCode == HttpStatusCode.Conflict) { throw Error.Conflict( @@ -142,23 +155,48 @@ public async Task CreateAsync(TEntity entity) public async Task ReplaceAsync(TEntity entity) { + var id = ResolveIdValue(entity); + var partitionKey = ResolvePartitionKey(entity); + + // never persist an eTag into the document body — queries would + // deserialize a stale value and cause false 412s on later replaces + var eTag = ResolveETagValue(entity); + SetETagValue(entity, null); + try { - var id = ResolveIdValue(entity); - var partitionKey = ResolvePartitionKey(entity); var replaceResponse = await _container.ReplaceItemAsync( entity, id, - partitionKey.CosmosPartitionKey); + partitionKey.CosmosPartitionKey, + new ItemRequestOptions + { + IfMatchEtag = eTag + }); - return replaceResponse.Resource; + var replacedEntity = replaceResponse.Resource; + SetETagValue(replacedEntity, replaceResponse.ETag); + SetETagValue(entity, replaceResponse.ETag); + return replacedEntity; } catch (CosmosException cosmosException) { + // restore the guard, otherwise a caller-level retry with this + // instance would silently fall back to an unconditional replace + SetETagValue(entity, eTag); + + if (cosmosException.StatusCode == HttpStatusCode.PreconditionFailed) + { + throw Error.PreconditionFailed( + "EtagMismatch", + $"The eTag of the entity with id {id} does not match the version in the database", + cosmosException); + } + if (cosmosException.StatusCode == HttpStatusCode.NotFound) { throw DatabaseError.EntityNotFound( - ResolveIdValue(entity), + id, ResolvePartitionKeyValue(entity), innerException: cosmosException); } @@ -170,28 +208,61 @@ public async Task ReplaceAsync(TEntity entity) public async Task UpsertAsync(TEntity entity) { var partitionKey = ResolvePartitionKey(entity); - var upsertResponse = await _container.UpsertItemAsync( - entity, - partitionKey.CosmosPartitionKey, - new ItemRequestOptions - { - EnableContentResponseOnWrite = true - }); - return upsertResponse.Resource; + // upserts stay unconditional by design, but the eTag must still + // not be persisted into the document body + var eTag = ResolveETagValue(entity); + SetETagValue(entity, null); + + try + { + var upsertResponse = await _container.UpsertItemAsync( + entity, + partitionKey.CosmosPartitionKey, + new ItemRequestOptions + { + EnableContentResponseOnWrite = true + }); + + var upsertedEntity = upsertResponse.Resource; + SetETagValue(upsertedEntity, upsertResponse.ETag); + SetETagValue(entity, upsertResponse.ETag); + return upsertedEntity; + } + catch + { + SetETagValue(entity, eTag); + throw; + } } public async Task UpsertAsync(TEntity entity, string partitionKey) { - var upsertResponse = await _container.UpsertItemAsync( - entity, - new PartitionKey(partitionKey).CosmosPartitionKey, - new ItemRequestOptions - { - EnableContentResponseOnWrite = true - }); + // upserts stay unconditional by design, but the eTag must still + // not be persisted into the document body + var eTag = ResolveETagValue(entity); + SetETagValue(entity, null); + + try + { + var upsertResponse = await _container.UpsertItemAsync( + entity, + new PartitionKey(partitionKey).CosmosPartitionKey, + new ItemRequestOptions + { + EnableContentResponseOnWrite = true + }); - return upsertResponse.Resource; + var upsertedEntity = upsertResponse.Resource; + SetETagValue(upsertedEntity, upsertResponse.ETag); + SetETagValue(entity, upsertResponse.ETag); + return upsertedEntity; + } + catch + { + SetETagValue(entity, eTag); + throw; + } } public Task DeleteAsync(string id, string partitionKey) From a794bdda76e5f5ddad15ee23fc6d57f02ffefd60 Mon Sep 17 00:00:00 2001 From: Kane Vo Date: Wed, 10 Jun 2026 14:39:47 +0700 Subject: [PATCH 2/4] feat: update updatedAt in UpdateAsync --- .../RepositoryTestBase.UpdateAsync.cs | 39 +++++++++++++++++++ .../MultiTenantDatabaseRepository`1.Update.cs | 1 + .../DatabaseRepository`1.Update.cs | 2 + 3 files changed, 42 insertions(+) diff --git a/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Repositories/RepositoryTestBase.UpdateAsync.cs b/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Repositories/RepositoryTestBase.UpdateAsync.cs index bcff412..3915a45 100644 --- a/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Repositories/RepositoryTestBase.UpdateAsync.cs +++ b/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Repositories/RepositoryTestBase.UpdateAsync.cs @@ -56,6 +56,45 @@ void UpdateAction(User u) updatedUser.TenantId.ShouldBe(user.TenantId); } + [Fact] + public async Task UpdateAsync_ShouldRefreshTheUpdatedAtTimestamp() + { + // Arrange + await ResetAsync(); + var user = User.Faker.Generate(); + var originalUpdatedAt = user.UpdatedAt; + await MicrosoftUserRepository.CreateAsync(user); + + // Act + var updatedUser = await MicrosoftUserRepository.UpdateAsync( + user.Id, + user.TenantId, + u => u.Firstname = "Updated"); + + // Assert + updatedUser.UpdatedAt.Should().NotBe(originalUpdatedAt); + updatedUser.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1)); + } + + [Fact] + public async Task UpdateAsyncWithoutPartitionKey_ShouldRefreshTheUpdatedAtTimestamp() + { + // Arrange + await ResetAsync(); + var user = User.Faker.Generate(); + var originalUpdatedAt = user.UpdatedAt; + await MicrosoftUserRepository.CreateAsync(user); + + // Act + var updatedUser = await MicrosoftUserRepository.UpdateAsync( + user.Id, + u => u.Firstname = "Updated"); + + // Assert + updatedUser.UpdatedAt.Should().NotBe(originalUpdatedAt); + updatedUser.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1)); + } + [Fact] public async Task UpdateAsync_ShouldThrowIfTheItemNotExists() { diff --git a/src/core/Wemogy.Infrastructure.Database.Core/Plugins/MultiTenantDatabase/Repositories/MultiTenantDatabaseRepository`1.Update.cs b/src/core/Wemogy.Infrastructure.Database.Core/Plugins/MultiTenantDatabase/Repositories/MultiTenantDatabaseRepository`1.Update.cs index e5a4069..75b57a7 100644 --- a/src/core/Wemogy.Infrastructure.Database.Core/Plugins/MultiTenantDatabase/Repositories/MultiTenantDatabaseRepository`1.Update.cs +++ b/src/core/Wemogy.Infrastructure.Database.Core/Plugins/MultiTenantDatabase/Repositories/MultiTenantDatabaseRepository`1.Update.cs @@ -50,6 +50,7 @@ public async Task UpdateAsync(string id, Func updateActi { var entity = await GetAsync(id); await updateAction(entity); + entity.UpdatedAt = DateTime.UtcNow; var updatedEntity = await ReplaceAsync(entity); return updatedEntity; } diff --git a/src/core/Wemogy.Infrastructure.Database.Core/Repositories/DatabaseRepository`1.Update.cs b/src/core/Wemogy.Infrastructure.Database.Core/Repositories/DatabaseRepository`1.Update.cs index f7308fa..3e5303b 100644 --- a/src/core/Wemogy.Infrastructure.Database.Core/Repositories/DatabaseRepository`1.Update.cs +++ b/src/core/Wemogy.Infrastructure.Database.Core/Repositories/DatabaseRepository`1.Update.cs @@ -36,6 +36,7 @@ public async Task UpdateAsync(string id, string partitionKey, Func UpdateAsync(string id, Func updateActi { var entity = await GetAsync(id); await updateAction(entity); + entity.UpdatedAt = DateTime.UtcNow; var updatedEntity = await _database.ReplaceAsync(entity); return updatedEntity; } From 18d3b2bb16d7ceacb765226ed6f583b4bb13648c Mon Sep 17 00:00:00 2001 From: Kane Vo Date: Wed, 10 Jun 2026 15:25:54 +0700 Subject: [PATCH 3/4] feat: add etag to entity base class --- .../Abstractions/EntityBase.cs | 3 +++ .../Abstractions/IEntityBase.cs | 2 ++ .../Fakes/UserWithETag.cs | 3 --- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/core/Wemogy.Infrastructure.Database.Core/Abstractions/EntityBase.cs b/src/core/Wemogy.Infrastructure.Database.Core/Abstractions/EntityBase.cs index 644d2f2..e85fece 100644 --- a/src/core/Wemogy.Infrastructure.Database.Core/Abstractions/EntityBase.cs +++ b/src/core/Wemogy.Infrastructure.Database.Core/Abstractions/EntityBase.cs @@ -23,6 +23,9 @@ protected EntityBase(string id) [Id] public string Id { get; set; } + [ETag] + public string? ETag { get; set; } + public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } diff --git a/src/core/Wemogy.Infrastructure.Database.Core/Abstractions/IEntityBase.cs b/src/core/Wemogy.Infrastructure.Database.Core/Abstractions/IEntityBase.cs index fa95382..0d2915e 100644 --- a/src/core/Wemogy.Infrastructure.Database.Core/Abstractions/IEntityBase.cs +++ b/src/core/Wemogy.Infrastructure.Database.Core/Abstractions/IEntityBase.cs @@ -12,4 +12,6 @@ public interface IEntityBase public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } + + public string? ETag { get; set; } } diff --git a/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Fakes/UserWithETag.cs b/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Fakes/UserWithETag.cs index bc919e3..33a5bf6 100644 --- a/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Fakes/UserWithETag.cs +++ b/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Fakes/UserWithETag.cs @@ -17,7 +17,4 @@ public class UserWithETag : EntityBase public string Firstname { get; set; } = string.Empty; public string Lastname { get; set; } = string.Empty; - - [ETag] - public string? ETag { get; set; } } From 7ad84e6358f8ffcf11a28ed54786581612c0f682 Mon Sep 17 00:00:00 2001 From: Kane Vo Date: Wed, 10 Jun 2026 15:38:09 +0700 Subject: [PATCH 4/4] chore: fix tests compliation errors --- .../Abstractions/DatabaseClientBaseTests.cs | 12 ++++++------ .../Repositories/RepositoryTestBase.UpdateAsync.cs | 8 ++++---- .../Repositories/CosmosDatabaseRepositoryTests.cs | 12 ++++++------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Abstractions/DatabaseClientBaseTests.cs b/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Abstractions/DatabaseClientBaseTests.cs index 7a3f3ae..fdb99b9 100644 --- a/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Abstractions/DatabaseClientBaseTests.cs +++ b/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Abstractions/DatabaseClientBaseTests.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using Shouldly; using Wemogy.Infrastructure.Database.Core.Abstractions; using Wemogy.Infrastructure.Database.Core.Attributes; using Xunit; @@ -21,7 +21,7 @@ public void ResolveETagValue_ShouldReturnETagPropertyValue() var eTag = client.ResolveETag(entity); // Assert - eTag.Should().Be("etag-123"); + eTag.ShouldBe("etag-123"); } [Fact] @@ -35,7 +35,7 @@ public void SetETagValue_ShouldSetETagProperty() client.SetETag(entity, "etag-456"); // Assert - entity.ETag.Should().Be("etag-456"); + entity.ETag.ShouldBe("etag-456"); } [Fact] @@ -52,7 +52,7 @@ public void SetETagValue_ShouldSupportNull() client.SetETag(entity, null); // Assert - entity.ETag.Should().BeNull(); + entity.ETag.ShouldBeNull(); } [Fact] @@ -66,7 +66,7 @@ public void ResolveETagValue_ShouldReturnNullForEntityWithoutETagProperty() var eTag = client.ResolveETag(entity); // Assert - eTag.Should().BeNull(); + eTag.ShouldBeNull(); } [Fact] @@ -80,7 +80,7 @@ public void SetETagValue_ShouldBeNoOpForEntityWithoutETagProperty() var exception = Record.Exception(() => client.SetETag(entity, "etag-123")); // Assert - exception.Should().BeNull(); + exception.ShouldBeNull(); } private class EntityWithETag diff --git a/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Repositories/RepositoryTestBase.UpdateAsync.cs b/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Repositories/RepositoryTestBase.UpdateAsync.cs index 3915a45..21bdf72 100644 --- a/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Repositories/RepositoryTestBase.UpdateAsync.cs +++ b/src/core/Wemogy.Infrastructure.Database.Core.UnitTests/Repositories/RepositoryTestBase.UpdateAsync.cs @@ -72,8 +72,8 @@ public async Task UpdateAsync_ShouldRefreshTheUpdatedAtTimestamp() u => u.Firstname = "Updated"); // Assert - updatedUser.UpdatedAt.Should().NotBe(originalUpdatedAt); - updatedUser.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1)); + updatedUser.UpdatedAt.ShouldNotBe(originalUpdatedAt); + updatedUser.UpdatedAt.ShouldBe(DateTime.UtcNow, TimeSpan.FromMinutes(1)); } [Fact] @@ -91,8 +91,8 @@ public async Task UpdateAsyncWithoutPartitionKey_ShouldRefreshTheUpdatedAtTimest u => u.Firstname = "Updated"); // Assert - updatedUser.UpdatedAt.Should().NotBe(originalUpdatedAt); - updatedUser.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1)); + updatedUser.UpdatedAt.ShouldNotBe(originalUpdatedAt); + updatedUser.UpdatedAt.ShouldBe(DateTime.UtcNow, TimeSpan.FromMinutes(1)); } [Fact] diff --git a/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Repositories/CosmosDatabaseRepositoryTests.cs b/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Repositories/CosmosDatabaseRepositoryTests.cs index e0e885b..1fc32d6 100644 --- a/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Repositories/CosmosDatabaseRepositoryTests.cs +++ b/src/cosmos/Wemogy.Infrastructure.Database.Cosmos.UnitTests/Repositories/CosmosDatabaseRepositoryTests.cs @@ -1,6 +1,6 @@ using System; using System.Threading.Tasks; -using FluentAssertions; +using Shouldly; using Wemogy.Core.Errors.Exceptions; using Wemogy.Infrastructure.Database.Core.Abstractions; using Wemogy.Infrastructure.Database.Core.UnitTests.DatabaseRepositories; @@ -69,8 +69,8 @@ await _userWithETagRepository.UpdateAsync( // Assert: both writes survived — proves the 412 fired and the retry re-read. // Without the eTag guard the outer replace would overwrite Lastname. - updatedUser.Firstname.Should().Be("Updated"); - updatedUser.Lastname.Should().Be("Concurrent"); + updatedUser.Firstname.ShouldBe("Updated"); + updatedUser.Lastname.ShouldBe("Concurrent"); } [Fact] @@ -95,13 +95,13 @@ public async Task ReplaceAsync_ShouldThrowPreconditionFailedForStaleETag() () => _userWithETagRepository.ReplaceAsync(staleUser)); // Assert - exception.Should().BeOfType(); + exception.ShouldBeOfType(); // last write must NOT have won var persistedUser = await _userWithETagRepository.GetAsync( user.Id, user.TenantId); - persistedUser.Firstname.Should().Be("Fresh"); + persistedUser.Firstname.ShouldBe("Fresh"); } [Fact] @@ -119,7 +119,7 @@ public async Task UpdateAsync_OnEntityWithoutETagProperty_ShouldStillWork() d => d.Location = "Updated"); // Assert - updatedDataCenter.Location.Should().Be("Updated"); + updatedDataCenter.Location.ShouldBe("Updated"); } private static UserWithETag NewUserWithETag()