Skip to content

Commit 08d2049

Browse files
author
Adrian Hall
committed
(#358) Default Offline conflict resolver.
1 parent 83eb0be commit 08d2049

File tree

3 files changed

+88
-3
lines changed

3 files changed

+88
-3
lines changed

src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptionsBuilder.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ namespace CommunityToolkit.Datasync.Client.Offline;
1414
public class DatasyncOfflineOptionsBuilder
1515
{
1616
internal IHttpClientFactory? _httpClientFactory;
17+
internal IConflictResolver? _defaultConflictResolver;
1718
internal readonly Dictionary<string, EntityOfflineOptions> _entities;
1819

1920
/// <summary>
@@ -78,6 +79,19 @@ public DatasyncOfflineOptionsBuilder UseHttpClientOptions(HttpClientOptions clie
7879
return this;
7980
}
8081

82+
/// <summary>
83+
/// Sets the default conflict resolver to use for all entities that do not have a specific
84+
/// conflict resolver set.
85+
/// </summary>
86+
/// <param name="conflictResolver">The default conflict resolver.</param>
87+
/// <returns>The current builder for chaining.</returns>
88+
public DatasyncOfflineOptionsBuilder UseDefaultConflictResolver(IConflictResolver conflictResolver)
89+
{
90+
ArgumentNullException.ThrowIfNull(conflictResolver);
91+
this._defaultConflictResolver = conflictResolver;
92+
return this;
93+
}
94+
8195
/// <summary>
8296
/// Configures the specified entity type for offline operations.
8397
/// </summary>
@@ -133,7 +147,8 @@ internal OfflineOptions Build()
133147

134148
OfflineOptions result = new()
135149
{
136-
HttpClientFactory = this._httpClientFactory
150+
HttpClientFactory = this._httpClientFactory,
151+
DefaultConflictResolver = this._defaultConflictResolver
137152
};
138153

139154
foreach (EntityOfflineOptions entity in this._entities.Values)

src/CommunityToolkit.Datasync.Client/Offline/Models/OfflineOptions.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ internal class OfflineOptions()
2020
/// </summary>
2121
public required IHttpClientFactory HttpClientFactory { get; init; }
2222

23+
/// <summary>
24+
/// The default <see cref="IConflictResolver"/> to use for this request.
25+
/// </summary>
26+
public IConflictResolver? DefaultConflictResolver { get; set; }
27+
2328
/// <summary>
2429
/// Adds an entity to the mapping of options.
2530
/// </summary>
@@ -50,7 +55,7 @@ public EntityDatasyncOptions GetOptions(Type entityType)
5055
{
5156
return new()
5257
{
53-
ConflictResolver = options.ConflictResolver,
58+
ConflictResolver = options.ConflictResolver ?? DefaultConflictResolver,
5459
Endpoint = options.Endpoint,
5560
HttpClient = HttpClientFactory.CreateClient(options.ClientName),
5661
QueryDescription = options.QueryDescription ?? new QueryDescription()
@@ -60,7 +65,7 @@ public EntityDatasyncOptions GetOptions(Type entityType)
6065
{
6166
return new()
6267
{
63-
ConflictResolver = null,
68+
ConflictResolver = DefaultConflictResolver,
6469
Endpoint = new Uri($"tables/{entityType.Name.ToLowerInvariant()}", UriKind.Relative),
6570
HttpClient = HttpClientFactory.CreateClient(),
6671
QueryDescription = new QueryDescription()

tests/CommunityToolkit.Datasync.Client.Test/Offline/ConflictResolver_Tests.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,71 @@ public async Task GenericConflictResolver_BothNull_ShouldReturnDefault()
165165

166166
#region Integration with OperationsQueueManager Tests
167167

168+
[Fact]
169+
public async Task PushAsync_WithDefaultClientWinsResolver_ShouldResolveConflictAndRetry()
170+
{
171+
// Arrange
172+
var context = CreateContext();
173+
174+
// Configure context to use client wins resolver
175+
context.Configurator = builder =>
176+
{
177+
builder.UseDefaultConflictResolver(new ClientWinsConflictResolver());
178+
builder.Entity<ClientMovie>(c =>
179+
{
180+
c.ClientName = "movies";
181+
c.Endpoint = new Uri("/tables/movies", UriKind.Relative);
182+
});
183+
};
184+
185+
// Create a client movie and save it to generate operation
186+
var clientMovie = new ClientMovie(TestData.Movies.BlackPanther)
187+
{
188+
Id = Guid.NewGuid().ToString("N"),
189+
Title = "Client Title"
190+
};
191+
context.Movies.Add(clientMovie);
192+
context.SaveChanges();
193+
194+
// Setup response for conflict followed by success
195+
var serverMovie = new ClientMovie(TestData.Movies.BlackPanther)
196+
{
197+
Id = clientMovie.Id,
198+
Title = "Server Title",
199+
UpdatedAt = DateTimeOffset.UtcNow,
200+
Version = Guid.NewGuid().ToString()
201+
};
202+
string serverJson = DatasyncSerializer.Serialize(serverMovie);
203+
204+
// First response is a conflict
205+
context.Handler.AddResponseContent(serverJson, HttpStatusCode.Conflict);
206+
207+
// Second response (after resolution) is success
208+
var finalMovie = new ClientMovie(TestData.Movies.BlackPanther)
209+
{
210+
Id = clientMovie.Id,
211+
Title = "Client Title", // This should match the client version after resolution
212+
UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1),
213+
Version = Guid.NewGuid().ToString()
214+
};
215+
string finalJson = DatasyncSerializer.Serialize(finalMovie);
216+
context.Handler.AddResponseContent(finalJson, HttpStatusCode.OK);
217+
218+
// Act
219+
var result = await context.QueueManager.PushAsync([typeof(ClientMovie)], new PushOptions());
220+
221+
// Assert
222+
result.IsSuccessful.Should().BeTrue();
223+
result.CompletedOperations.Should().Be(1);
224+
result.FailedRequests.Should().BeEmpty();
225+
226+
// Verify the database has the right value
227+
var savedMovie = context.Movies.Find(clientMovie.Id);
228+
savedMovie.Should().NotBeNull();
229+
savedMovie!.Title.Should().Be("Client Title");
230+
savedMovie.Version.Should().Be(finalMovie.Version);
231+
}
232+
168233
[Fact]
169234
public async Task PushAsync_WithClientWinsResolver_ShouldResolveConflictAndRetry()
170235
{

0 commit comments

Comments
 (0)