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
+ });
+ }
+}