diff --git a/src/CommunityToolkit.Datasync.Client/Offline/ConflictResolvers.cs b/src/CommunityToolkit.Datasync.Client/Offline/ConflictResolvers.cs new file mode 100644 index 00000000..906e6c8b --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/ConflictResolvers.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.Datasync.Client.Offline; + +/// +/// An abstract class that provides a mechanism for resolving conflicts between client and server objects of a specified +/// type asynchronously. The object edition of the conflict resolver just calls the typed version. +/// +/// The type of entity being resolved. +public abstract class AbstractConflictResolver : IConflictResolver +{ + /// + public abstract Task ResolveConflictAsync(TEntity? clientObject, TEntity? serverObject, CancellationToken cancellationToken = default); + + /// + /// The object version of the resolver calls the typed version. + /// + /// + /// + /// + /// + public virtual async Task ResolveConflictAsync(object? clientObject, object? serverObject, CancellationToken cancellationToken = default) + => await ResolveConflictAsync((TEntity?)clientObject, (TEntity?)serverObject, cancellationToken); +} + +/// +/// A conflict resolver where the client object always wins. +/// +public class ClientWinsConflictResolver : IConflictResolver +{ + /// + public Task ResolveConflictAsync(object? clientObject, object? serverObject, CancellationToken cancellationToken = default) + => Task.FromResult(new ConflictResolution + { + Result = ConflictResolutionResult.Client, + Entity = clientObject + }); +} + +/// +/// A conflict resolver where the server object always wins. +/// +public class ServerWinsConflictResolver : IConflictResolver +{ + /// + public Task ResolveConflictAsync(object? clientObject, object? serverObject, CancellationToken cancellationToken = default) + => Task.FromResult(new ConflictResolution + { + Result = ConflictResolutionResult.Server, + Entity = serverObject + }); +} + diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptionsBuilder.cs b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptionsBuilder.cs index dd3b8595..66770226 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptionsBuilder.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptionsBuilder.cs @@ -95,6 +95,7 @@ public DatasyncOfflineOptionsBuilder Entity(Action entity = new(); configure(entity); options.ClientName = entity.ClientName; + options.ConflictResolver = entity.ConflictResolver; options.Endpoint = entity.Endpoint; options.QueryDescription = new QueryTranslator(entity.Query).Translate(); return this; @@ -137,7 +138,7 @@ internal OfflineOptions Build() foreach (EntityOfflineOptions entity in this._entities.Values) { - result.AddEntity(entity.EntityType, entity.ClientName, entity.Endpoint, entity.QueryDescription); + result.AddEntity(entity.EntityType, entity.ClientName, entity.ConflictResolver, entity.Endpoint, entity.QueryDescription); } return result; @@ -164,6 +165,11 @@ public class EntityOfflineOptions(Type entityType) /// public Uri Endpoint { get; set; } = new Uri($"/tables/{entityType.Name.ToLowerInvariant()}", UriKind.Relative); + /// + /// The conflict resolver for this entity. + /// + public IConflictResolver? ConflictResolver { get; set; } + /// /// The query description for the entity type - may be null (to mean "pull everything"). /// @@ -186,6 +192,11 @@ public class EntityOfflineOptions() where TEntity : class /// public string ClientName { get; set; } = string.Empty; + /// + /// The conflict resolver for this entity. + /// + public IConflictResolver? ConflictResolver { get; set; } + /// /// The endpoint for the entity type. /// diff --git a/src/CommunityToolkit.Datasync.Client/Offline/IConflictResolver.cs b/src/CommunityToolkit.Datasync.Client/Offline/IConflictResolver.cs new file mode 100644 index 00000000..92fcac6a --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/IConflictResolver.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.Datasync.Client.Offline; + +/// +/// Definition of a conflict resolver. This is used in push situations where +/// the server returns a 409 or 412 status code indicating that the client is +/// out of step with the server. +/// +public interface IConflictResolver +{ + /// + /// Resolves the conflict between two objects - client side and server side. + /// + /// The client object. + /// The server object. + /// A to observe. + /// The conflict resolution. + Task ResolveConflictAsync(object? clientObject, object? serverObject, CancellationToken cancellationToken = default); +} + +/// +/// Definition of a conflict resolver. This is used in push situations where +/// the server returns a 409 or 412 status code indicating that the client is +/// out of step with the server. +/// +/// The type of the entity. +public interface IConflictResolver : IConflictResolver +{ + /// + /// Resolves the conflict between two objects - client side and server side. + /// + /// The client object. + /// The server object. + /// A to observe. + /// The conflict resolution. + Task ResolveConflictAsync(TEntity? clientObject, TEntity? serverObject, CancellationToken cancellationToken = default); +} diff --git a/src/CommunityToolkit.Datasync.Client/Offline/Models/ConflictResolution.cs b/src/CommunityToolkit.Datasync.Client/Offline/Models/ConflictResolution.cs new file mode 100644 index 00000000..654bbe9e --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/Models/ConflictResolution.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.Datasync.Client.Offline; + +/// +/// The possible results of a conflict resolution. +/// +public enum ConflictResolutionResult +{ + /// + /// The default resolution, which is to do nothing and re-queue the operation. + /// + Default, + + /// + /// The provided client object should be used. This results in a new "force" submission + /// to the server to over-write the server entity. + /// + Client, + + /// + /// The server object should be used. This results in the client object being updated + /// with whatever the server object was provided. + /// + Server +} + +/// +/// The model class returned by a conflict resolver to indicate the resolution of the conflict. +/// +public class ConflictResolution +{ + /// + /// The conflict resolution result. + /// + public ConflictResolutionResult Result { get; set; } = ConflictResolutionResult.Default; + + /// + /// The entity, if required. + /// + public object? Entity { get; set; } +} diff --git a/src/CommunityToolkit.Datasync.Client/Offline/Models/EntityDatasyncOptions.cs b/src/CommunityToolkit.Datasync.Client/Offline/Models/EntityDatasyncOptions.cs index 856212f6..4b408170 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/Models/EntityDatasyncOptions.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/Models/EntityDatasyncOptions.cs @@ -11,6 +11,11 @@ namespace CommunityToolkit.Datasync.Client.Offline.Models; /// internal class EntityDatasyncOptions { + /// + /// The conflict resolver for the entity. + /// + internal IConflictResolver? ConflictResolver { get; init; } + /// /// The endpoint for the entity type. /// diff --git a/src/CommunityToolkit.Datasync.Client/Offline/Models/OfflineOptions.cs b/src/CommunityToolkit.Datasync.Client/Offline/Models/OfflineOptions.cs index 47699d02..997e9ce9 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/Models/OfflineOptions.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/Models/OfflineOptions.cs @@ -25,11 +25,18 @@ internal class OfflineOptions() /// /// The type of the entity being stored. /// The name of the client. + /// The conflict resolver to use. /// The endpoint serving the datasync services. /// The optional query description to describe what entities need to be pulled. - public void AddEntity(Type entityType, string clientName, Uri endpoint, QueryDescription? queryDescription = null) + public void AddEntity(Type entityType, string clientName, IConflictResolver? conflictResolver, Uri endpoint, QueryDescription? queryDescription = null) { - this._cache.Add(entityType, new EntityOptions { ClientName = clientName, Endpoint = endpoint, QueryDescription = queryDescription }); + this._cache.Add(entityType, new EntityOptions + { + ClientName = clientName, + ConflictResolver = conflictResolver, + Endpoint = endpoint, + QueryDescription = queryDescription + }); } /// @@ -43,6 +50,7 @@ public EntityDatasyncOptions GetOptions(Type entityType) { return new() { + ConflictResolver = options.ConflictResolver, Endpoint = options.Endpoint, HttpClient = HttpClientFactory.CreateClient(options.ClientName), QueryDescription = options.QueryDescription ?? new QueryDescription() @@ -52,6 +60,7 @@ public EntityDatasyncOptions GetOptions(Type entityType) { return new() { + ConflictResolver = null, Endpoint = new Uri($"tables/{entityType.Name.ToLowerInvariant()}", UriKind.Relative), HttpClient = HttpClientFactory.CreateClient(), QueryDescription = new QueryDescription() @@ -69,6 +78,11 @@ internal class EntityOptions /// public required string ClientName { get; set; } + /// + /// The conflict resolver for the entity options. + /// + internal IConflictResolver? ConflictResolver { get; set; } + /// /// The endpoint for the entity type. /// diff --git a/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs b/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs index ec44fa0e..3e09fa24 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs @@ -59,19 +59,19 @@ public async Task ExecuteAsync(IEnumerable requests, Pu { _ = context.Add(item); result.IncrementAdditions(); - } + } else if (originalEntity is not null && metadata.Deleted) { _ = context.Remove(originalEntity); result.IncrementDeletions(); - } + } else if (originalEntity is not null && !metadata.Deleted) { context.Entry(originalEntity).CurrentValues.SetValues(item); result.IncrementReplacements(); } - if (metadata.UpdatedAt.HasValue && metadata.UpdatedAt.Value > lastSynchronization) + if (metadata.UpdatedAt > lastSynchronization) { lastSynchronization = metadata.UpdatedAt.Value; bool isAdded = await DeltaTokenStore.SetDeltaTokenAsync(pullResponse.QueryId, metadata.UpdatedAt.Value, cancellationToken).ConfigureAwait(false); diff --git a/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs b/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs index 911174c5..d8ff086f 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs @@ -8,6 +8,7 @@ using CommunityToolkit.Datasync.Client.Threading; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; +using System.Net; using System.Reflection; using System.Text.Json; @@ -77,7 +78,7 @@ internal List GetChangedEntitiesInScope() /// /// /// An entity is "synchronization ready" if: - /// + /// /// * It is a property on this context /// * The property is public and a . /// * The property does not have a specified. @@ -215,7 +216,7 @@ internal IEnumerable GetSynchronizableEntityTypes(IEnumerable allowe /// /// /// An entity is "synchronization ready" if: - /// + /// /// * It is a property on this context /// * The property is public and a . /// * The property does not have a specified. @@ -299,7 +300,40 @@ internal async Task PushAsync(IEnumerable entityTypes, PushOpt ExecutableOperation op = await ExecutableOperation.CreateAsync(operation, cancellationToken).ConfigureAwait(false); ServiceResponse response = await op.ExecuteAsync(options, cancellationToken).ConfigureAwait(false); - if (!response.IsSuccessful) + bool isSuccessful = response.IsSuccessful; + if (response.IsConflictStatusCode && options.ConflictResolver is not null) + { + object? serverEntity = JsonSerializer.Deserialize(response.ContentStream, entityType, DatasyncSerializer.JsonSerializerOptions); + object? clientEntity = JsonSerializer.Deserialize(operation.Item, entityType, DatasyncSerializer.JsonSerializerOptions); + ConflictResolution resolution = await options.ConflictResolver.ResolveConflictAsync(clientEntity, serverEntity, cancellationToken).ConfigureAwait(false); + + if (resolution.Result is ConflictResolutionResult.Client) + { + operation.Item = JsonSerializer.Serialize(resolution.Entity, entityType, DatasyncSerializer.JsonSerializerOptions); + operation.State = OperationState.Pending; + operation.LastAttempt = DateTimeOffset.UtcNow; + operation.HttpStatusCode = response.StatusCode; + operation.EntityVersion = string.Empty; // Force the push + operation.Version++; + _ = this._context.Update(operation); + ExecutableOperation resolvedOp = await ExecutableOperation.CreateAsync(operation, cancellationToken).ConfigureAwait(false); + response = await resolvedOp.ExecuteAsync(options, cancellationToken).ConfigureAwait(false); + isSuccessful = response.IsSuccessful; + } + else if (resolution.Result is ConflictResolutionResult.Server) + { + lock (this.pushlock) + { + operation.State = OperationState.Completed; // Make it successful + operation.LastAttempt = DateTimeOffset.UtcNow; + operation.HttpStatusCode = 200; + isSuccessful = true; + _ = this._context.Update(operation); + } + } + } + + if (!isSuccessful) { lock (this.pushlock) { @@ -315,6 +349,7 @@ internal async Task PushAsync(IEnumerable entityTypes, PushOpt // If the operation is a success, then the content may need to be updated. if (operation.Kind != OperationKind.Delete) { + _ = response.ContentStream.Seek(0L, SeekOrigin.Begin); // Reset the memory stream to the beginning. object? newValue = JsonSerializer.Deserialize(response.ContentStream, entityType, DatasyncSerializer.JsonSerializerOptions); object? oldValue = await this._context.FindAsync(entityType, [operation.ItemId], cancellationToken).ConfigureAwait(false); ReplaceDatabaseValue(oldValue, newValue); diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/ConflictResolver_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/ConflictResolver_Tests.cs new file mode 100644 index 00000000..f25f9305 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/ConflictResolver_Tests.cs @@ -0,0 +1,639 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Client.Offline; +using CommunityToolkit.Datasync.Client.Serialization; +using CommunityToolkit.Datasync.Client.Test.Offline.Helpers; +using CommunityToolkit.Datasync.TestCommon.Databases; +using CommunityToolkit.Datasync.TestCommon.Models; +using System.Net; +using TestData = CommunityToolkit.Datasync.TestCommon.TestData; + +namespace CommunityToolkit.Datasync.Client.Test.Offline; + +#pragma warning disable IDE0008 // Use explicit type + +[ExcludeFromCodeCoverage] +[Collection("SynchronizedOfflineTests")] +public class ConflictResolver_Tests : BaseTest +{ + #region Built-in Conflict Resolvers Tests + + [Fact] + public async Task ClientWinsConflictResolver_ShouldReturnClientObject() + { + // Arrange + var clientResolver = new ClientWinsConflictResolver(); + var clientObject = new ClientMovie(TestData.Movies.BlackPanther) { Id = "test-id" }; + var serverObject = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = "test-id", + Title = "Updated on Server" + }; + + // Act + var resolution = await clientResolver.ResolveConflictAsync(clientObject, serverObject); + + // Assert + resolution.Result.Should().Be(ConflictResolutionResult.Client); + resolution.Entity.Should().BeSameAs(clientObject); + } + + [Fact] + public async Task ServerWinsConflictResolver_ShouldReturnServerObject() + { + // Arrange + var serverResolver = new ServerWinsConflictResolver(); + var clientObject = new ClientMovie(TestData.Movies.BlackPanther) { Id = "test-id" }; + var serverObject = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = "test-id", + Title = "Updated on Server" + }; + + // Act + var resolution = await serverResolver.ResolveConflictAsync(clientObject, serverObject); + + // Assert + resolution.Result.Should().Be(ConflictResolutionResult.Server); + resolution.Entity.Should().BeSameAs(serverObject); + } + + [Fact] + public async Task ClientWinsConflictResolver_ShouldReturnClientObject_WithNullServerObject() + { + // Arrange + var clientResolver = new ClientWinsConflictResolver(); + var clientObject = new ClientMovie(TestData.Movies.BlackPanther) { Id = "test-id" }; + + // Act + var resolution = await clientResolver.ResolveConflictAsync(clientObject, null); + + // Assert + resolution.Result.Should().Be(ConflictResolutionResult.Client); + resolution.Entity.Should().BeSameAs(clientObject); + } + + [Fact] + public async Task ServerWinsConflictResolver_ShouldReturnServerObject_WithNullClientObject() + { + // Arrange + var serverResolver = new ServerWinsConflictResolver(); + var serverObject = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = "test-id", + Title = "Updated on Server" + }; + + // Act + var resolution = await serverResolver.ResolveConflictAsync(null, serverObject); + + // Assert + resolution.Result.Should().Be(ConflictResolutionResult.Server); + resolution.Entity.Should().BeSameAs(serverObject); + } + + #endregion + + #region Generic Conflict Resolver Tests + + [Fact] + public async Task GenericConflictResolver_ShouldResolveTypedConflict() + { + // Arrange + var resolver = new TestGenericConflictResolver(); + var clientObject = new ClientMovie(TestData.Movies.BlackPanther) { Id = "test-id", Rating = MovieRating.R }; + var serverObject = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = "test-id", + Title = "Updated on Server", + Rating = MovieRating.G + }; + + // Act + var resolution = await resolver.ResolveConflictAsync(clientObject, serverObject); + + // Assert + resolution.Result.Should().Be(ConflictResolutionResult.Client); + var mergedMovie = resolution.Entity as ClientMovie; + mergedMovie.Should().NotBeNull(); + mergedMovie!.Title.Should().Be(serverObject.Title); // From server + mergedMovie.Rating.Should().Be(clientObject.Rating); // From client + } + + [Fact] + public async Task GenericConflictResolver_ObjectMethod_ShouldCallTypedMethod() + { + // Arrange + var resolver = new TestGenericConflictResolver(); + var clientObject = new ClientMovie(TestData.Movies.BlackPanther) { Id = "test-id", Rating = MovieRating.R }; + var serverObject = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = "test-id", + Title = "Updated on Server", + Rating = MovieRating.G + }; + + // Act + var resolution = await resolver.ResolveConflictAsync((object)clientObject, (object)serverObject); + + // Assert + resolution.Result.Should().Be(ConflictResolutionResult.Client); + var movie = resolution.Entity as ClientMovie; + movie.Should().NotBeNull(); + movie!.Title.Should().Be(serverObject.Title); // From server + movie.Rating.Should().Be(clientObject.Rating); // From client + } + + // ...existing code... + [Fact] + public async Task GenericConflictResolver_BothNull_ShouldReturnDefault() + { + // Arrange + var resolver = new TestGenericConflictResolver(); + + // Act + var resolution = await resolver.ResolveConflictAsync(null, null); + + // Assert + resolution.Result.Should().Be(ConflictResolutionResult.Default); + resolution.Entity.Should().BeNull(); + } + + #endregion + + #region Integration with OperationsQueueManager Tests + + [Fact] + public async Task PushAsync_WithClientWinsResolver_ShouldResolveConflictAndRetry() + { + // Arrange + var context = CreateContext(); + + // Configure context to use client wins resolver + context.Configurator = builder => + { + builder.Entity(c => + { + c.ClientName = "movies"; + c.Endpoint = new Uri("/tables/movies", UriKind.Relative); + c.ConflictResolver = new ClientWinsConflictResolver(); + }); + }; + + // Create a client movie and save it to generate operation + var clientMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = Guid.NewGuid().ToString("N"), + Title = "Client Title" + }; + context.Movies.Add(clientMovie); + context.SaveChanges(); + + // Setup response for conflict followed by success + var serverMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = clientMovie.Id, + Title = "Server Title", + UpdatedAt = DateTimeOffset.UtcNow, + Version = Guid.NewGuid().ToString() + }; + string serverJson = DatasyncSerializer.Serialize(serverMovie); + + // First response is a conflict + context.Handler.AddResponseContent(serverJson, HttpStatusCode.Conflict); + + // Second response (after resolution) is success + var finalMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = clientMovie.Id, + Title = "Client Title", // This should match the client version after resolution + UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1), + Version = Guid.NewGuid().ToString() + }; + string finalJson = DatasyncSerializer.Serialize(finalMovie); + context.Handler.AddResponseContent(finalJson, HttpStatusCode.OK); + + // Act + var result = await context.QueueManager.PushAsync([typeof(ClientMovie)], new PushOptions()); + + // Assert + result.IsSuccessful.Should().BeTrue(); + result.CompletedOperations.Should().Be(1); + result.FailedRequests.Should().BeEmpty(); + + // Verify the database has the right value + var savedMovie = context.Movies.Find(clientMovie.Id); + savedMovie.Should().NotBeNull(); + savedMovie!.Title.Should().Be("Client Title"); + savedMovie.Version.Should().Be(finalMovie.Version); + } + + [Fact] + public async Task PushAsync_WithServerWinsResolver_ShouldResolveConflictAndRetry() + { + // Arrange + var context = CreateContext(); + + // Configure context to use server wins resolver + context.Configurator = builder => builder.Entity(c => + c.ConflictResolver = new ServerWinsConflictResolver()); + + // Create a client movie and save it to generate operation + var clientMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = Guid.NewGuid().ToString("N"), + Title = "Client Title" + }; + context.Movies.Add(clientMovie); + context.SaveChanges(); + + // Setup response for conflict followed by success + var serverMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = clientMovie.Id, + Title = "Server Title", + UpdatedAt = DateTimeOffset.UtcNow, + Version = Guid.NewGuid().ToString() + }; + string serverJson = DatasyncSerializer.Serialize(serverMovie); + + // First response is a conflict + context.Handler.AddResponseContent(serverJson, HttpStatusCode.Conflict); + + // Second response (after resolution) is success + var finalMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = clientMovie.Id, + Title = "Server Title", // This should match the server version after resolution + UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1), + Version = Guid.NewGuid().ToString() + }; + string finalJson = DatasyncSerializer.Serialize(finalMovie); + context.Handler.AddResponseContent(finalJson, HttpStatusCode.OK); + + // Act + var result = await context.QueueManager.PushAsync([typeof(ClientMovie)], new PushOptions()); + + // Assert + result.IsSuccessful.Should().BeTrue(); + result.CompletedOperations.Should().Be(1); + result.FailedRequests.Should().BeEmpty(); + + // Verify the database has the right value + var savedMovie = context.Movies.Find(clientMovie.Id); + savedMovie.Should().NotBeNull(); + savedMovie!.Title.Should().Be("Server Title"); + savedMovie.Version.Should().Be(serverMovie.Version); + } + + [Fact] + public async Task PushAsync_WithCustomResolver_ShouldResolveConflictAndRetry() + { + // Arrange + var context = CreateContext(); + + // Configure context to use custom resolver + context.Configurator = builder => builder.Entity(c => + c.ConflictResolver = new TestGenericConflictResolver()); + + // Create a client movie and save it to generate operation + var clientMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = Guid.NewGuid().ToString("N"), + Title = "Client Title", + Rating = MovieRating.G + }; + context.Movies.Add(clientMovie); + context.SaveChanges(); + + // Setup response for conflict followed by success + var serverMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = clientMovie.Id, + Title = "Server Title", + Rating = MovieRating.R, + UpdatedAt = DateTimeOffset.UtcNow, + Version = Guid.NewGuid().ToString() + }; + string serverJson = DatasyncSerializer.Serialize(serverMovie); + + // First response is a conflict + context.Handler.AddResponseContent(serverJson, HttpStatusCode.Conflict); + + // Second response (after resolution) is success + var finalMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = clientMovie.Id, + Title = "Server Title", // From server + Rating = MovieRating.G, // From client + UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1), + Version = Guid.NewGuid().ToString() + }; + string finalJson = DatasyncSerializer.Serialize(finalMovie); + context.Handler.AddResponseContent(finalJson, HttpStatusCode.OK); + + // Act + var result = await context.QueueManager.PushAsync([typeof(ClientMovie)], new PushOptions()); + + // Assert + result.IsSuccessful.Should().BeTrue(); + result.CompletedOperations.Should().Be(1); + result.FailedRequests.Should().BeEmpty(); + + // Verify the database has the right value + var savedMovie = context.Movies.Find(clientMovie.Id); + savedMovie.Should().NotBeNull(); + savedMovie!.Title.Should().Be("Server Title"); // From server + savedMovie.Rating.Should().Be(MovieRating.G); // From client + savedMovie.Version.Should().Be(finalMovie.Version); + } + + [Fact] + public async Task PushAsync_WithPreconditionFailed_ShouldResolveConflict() + { + // Arrange + var context = CreateContext(); + + // Configure context to use server wins resolver + context.Configurator = builder => builder.Entity(c => + c.ConflictResolver = new ServerWinsConflictResolver()); + + // Create a client movie and save it to generate operation + var clientMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = Guid.NewGuid().ToString("N"), + Title = "Client Title" + }; + context.Movies.Add(clientMovie); + context.SaveChanges(); + + // Setup response for conflict followed by success + var serverMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = clientMovie.Id, + Title = "Server Title", + UpdatedAt = DateTimeOffset.UtcNow, + Version = Guid.NewGuid().ToString() + }; + string serverJson = DatasyncSerializer.Serialize(serverMovie); + + // First response is a precondition failed (412) + context.Handler.AddResponseContent(serverJson, HttpStatusCode.PreconditionFailed); + + // Second response (after resolution) is success + var finalMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = clientMovie.Id, + Title = "Server Title", // This should match the server version after resolution + UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1), + Version = Guid.NewGuid().ToString() + }; + string finalJson = DatasyncSerializer.Serialize(finalMovie); + context.Handler.AddResponseContent(finalJson, HttpStatusCode.OK); + + // Act + var result = await context.QueueManager.PushAsync([typeof(ClientMovie)], new PushOptions()); + + // Assert + result.IsSuccessful.Should().BeTrue(); + result.CompletedOperations.Should().Be(1); + result.FailedRequests.Should().BeEmpty(); + + // Verify the database has the right value + var savedMovie = context.Movies.Find(clientMovie.Id); + savedMovie.Should().NotBeNull(); + savedMovie!.Title.Should().Be("Server Title"); + savedMovie.Version.Should().Be(serverMovie.Version); + } + + [Fact] + public async Task PushAsync_WithDeleteOperation_AndConflict_ShouldResolveConflict() + { + // Arrange + var context = CreateContext(); + + // Configure context to use client wins resolver + context.Configurator = builder => builder.Entity(c => + c.ConflictResolver = new ClientWinsConflictResolver()); + + // Create a client movie first + var clientMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = Guid.NewGuid().ToString("N"), + Title = "Original Title" + }; + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + // Now delete it to create a delete operation + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + // Setup response for conflict followed by success + var serverMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = clientMovie.Id, + Title = "Updated on server", + UpdatedAt = DateTimeOffset.UtcNow, + Version = Guid.NewGuid().ToString() + }; + string serverJson = DatasyncSerializer.Serialize(serverMovie); + + // First response is a conflict + context.Handler.AddResponseContent(serverJson, HttpStatusCode.Conflict); + + // Second response (after resolution) is success - a deleted entity should return 204 No Content + context.Handler.AddResponse(HttpStatusCode.NoContent); + + // Act + var result = await context.QueueManager.PushAsync([typeof(ClientMovie)], new PushOptions()); + + // Assert + result.IsSuccessful.Should().BeTrue(); + result.CompletedOperations.Should().Be(1); + result.FailedRequests.Should().BeEmpty(); + + // Verify the entity has been deleted + var savedMovie = context.Movies.Find(clientMovie.Id); + savedMovie.Should().BeNull(); + } + + [Fact] + public async Task PushAsync_WithDeleteOperation_AndConflict_ServerWinsResolver_ShouldResolveConflict() + { + // Arrange + var context = CreateContext(); + context.Configurator = builder => builder.Entity(c => + c.ConflictResolver = new ServerWinsConflictResolver()); + + // Create a client movie and save it, then delete it to create a local delete operation + var clientMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = Guid.NewGuid().ToString("N"), + Title = "Deleted Title" + }; + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + // Setup conflict (server version) followed by success + var serverMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = clientMovie.Id, + Title = "Server Title", + UpdatedAt = DateTimeOffset.UtcNow, + Version = Guid.NewGuid().ToString() + }; + context.Handler.AddResponseContent(DatasyncSerializer.Serialize(serverMovie), HttpStatusCode.Conflict); + context.Handler.AddResponse(HttpStatusCode.NoContent); + + // Act + var result = await context.QueueManager.PushAsync([typeof(ClientMovie)], new PushOptions()); + + // Assert + result.IsSuccessful.Should().BeTrue(); + result.CompletedOperations.Should().Be(1); + result.FailedRequests.Should().BeEmpty(); + + // ServerWinsResolver would restore the server entity, but the local request was a delete + // so final result in the local DB is that the entity remains deleted + context.Movies.Find(clientMovie.Id).Should().BeNull(); + } + + [Fact] + public async Task PushAsync_WithReplaceOperation_AndConflict_ShouldResolveConflict() + { + // Arrange + var context = CreateContext(); + context.Configurator = builder => builder.Entity(c => + c.ConflictResolver = new ServerWinsConflictResolver()); + + // Create a client movie and modify it to create a replace operation + var clientMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = Guid.NewGuid().ToString("N"), + Title = "Local Replacement" + }; + context.Movies.Add(clientMovie); + context.SaveChanges(); + + // Setup conflict (server version) followed by final success + var serverMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = clientMovie.Id, + Title = "Server Title", + UpdatedAt = DateTimeOffset.UtcNow, + Version = Guid.NewGuid().ToString() + }; + context.Handler.AddResponseContent(DatasyncSerializer.Serialize(serverMovie), HttpStatusCode.Conflict); + + // Act + var result = await context.QueueManager.PushAsync([typeof(ClientMovie)], new PushOptions()); + + // Assert + result.IsSuccessful.Should().BeTrue(); + result.CompletedOperations.Should().Be(1); + result.FailedRequests.Should().BeEmpty(); + + // Verify the database has the server's final value because ServerWins + var savedMovie = context.Movies.Find(serverMovie.Id); + savedMovie.Should().NotBeNull(); + savedMovie!.Title.Should().Be("Server Title"); + savedMovie.Version.Should().Be(serverMovie.Version); + } + + [Fact] + public async Task PushAsync_WithNull_ConflictResolver_ShouldNotResolveConflict() + { + // Arrange + var context = CreateContext(); + + // Create a client movie and save it to generate operation + var clientMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = Guid.NewGuid().ToString("N"), + Title = "Client Title" + }; + context.Movies.Add(clientMovie); + context.SaveChanges(); + + // Setup conflict response + var serverMovie = new ClientMovie(TestData.Movies.BlackPanther) + { + Id = clientMovie.Id, + Title = "Server Title", + UpdatedAt = DateTimeOffset.UtcNow, + Version = Guid.NewGuid().ToString() + }; + string serverJson = DatasyncSerializer.Serialize(serverMovie); + context.Handler.AddResponseContent(serverJson, HttpStatusCode.Conflict); + + // Act + var result = await context.QueueManager.PushAsync([typeof(ClientMovie)], new PushOptions()); + + // Assert + result.IsSuccessful.Should().BeFalse(); + result.CompletedOperations.Should().Be(0); + result.FailedRequests.Should().HaveCount(1); + + // Verify operation is still in queue and marked as failed + context.DatasyncOperationsQueue.Should().HaveCount(1); + var op = context.DatasyncOperationsQueue.Single(); + op.State.Should().Be(OperationState.Failed); + op.HttpStatusCode.Should().Be(409); + } + + #endregion +} + +// ...existing code... +public class TestGenericConflictResolver : AbstractConflictResolver +{ + public override Task ResolveConflictAsync(ClientMovie clientObject, ClientMovie serverObject, CancellationToken cancellationToken = default) + { + if (clientObject is null && serverObject is null) + { + return Task.FromResult(new ConflictResolution + { + Result = ConflictResolutionResult.Default + }); + } + + if (clientObject is null) + { + return Task.FromResult(new ConflictResolution + { + Result = ConflictResolutionResult.Server, + Entity = serverObject + }); + } + + if (serverObject is null) + { + return Task.FromResult(new ConflictResolution + { + Result = ConflictResolutionResult.Client, + Entity = clientObject + }); + } + + // Create a merged object - take title from server but rating from client + var mergedMovie = new ClientMovie(serverObject) + { + Id = serverObject.Id, + Title = serverObject.Title, + UpdatedAt = serverObject.UpdatedAt, + Version = serverObject.Version, + Rating = clientObject.Rating + }; + + return Task.FromResult(new ConflictResolution + { + Result = ConflictResolutionResult.Client, + Entity = mergedMovie + }); + } +}