diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DeltaTokenStore/DefaultDeltaTokenStore.cs b/src/CommunityToolkit.Datasync.Client/Offline/DeltaTokenStore/DefaultDeltaTokenStore.cs
index 034de048..1fb699bf 100644
--- a/src/CommunityToolkit.Datasync.Client/Offline/DeltaTokenStore/DefaultDeltaTokenStore.cs
+++ b/src/CommunityToolkit.Datasync.Client/Offline/DeltaTokenStore/DefaultDeltaTokenStore.cs
@@ -48,8 +48,8 @@ public async Task ResetDeltaTokenAsync(string queryId, CancellationToken cancell
/// The query ID of the table.
/// The value of the delta token.
/// A to observe.
- /// A task that completes when the delta token has been set in the persistent store.
- public async Task SetDeltaTokenAsync(string queryId, DateTimeOffset value, CancellationToken cancellationToken = default)
+ /// true if this query ID was set for the first time; false otherwise.
+ public async Task SetDeltaTokenAsync(string queryId, DateTimeOffset value, CancellationToken cancellationToken = default)
{
ValidateQueryId(queryId);
long unixms = value.ToUnixTimeMilliseconds();
@@ -57,12 +57,15 @@ public async Task SetDeltaTokenAsync(string queryId, DateTimeOffset value, Cance
if (deltaToken is null)
{
_ = context.DatasyncDeltaTokens.Add(new DatasyncDeltaToken() { Id = queryId, Value = unixms });
+ return true;
}
else if (deltaToken.Value != unixms)
{
deltaToken.Value = unixms;
_ = context.DatasyncDeltaTokens.Update(deltaToken);
}
+
+ return false;
}
///
diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DeltaTokenStore/IDeltaTokenStore.cs b/src/CommunityToolkit.Datasync.Client/Offline/DeltaTokenStore/IDeltaTokenStore.cs
index f13ac0c5..66dfc1e4 100644
--- a/src/CommunityToolkit.Datasync.Client/Offline/DeltaTokenStore/IDeltaTokenStore.cs
+++ b/src/CommunityToolkit.Datasync.Client/Offline/DeltaTokenStore/IDeltaTokenStore.cs
@@ -32,5 +32,5 @@ internal interface IDeltaTokenStore
/// The value of the delta token.
/// A to observe.
/// A task that completes when the delta token has been set in the persistent store.
- Task SetDeltaTokenAsync(string queryId, DateTimeOffset value, CancellationToken cancellationToken = default);
+ Task SetDeltaTokenAsync(string queryId, DateTimeOffset value, CancellationToken cancellationToken = default);
}
diff --git a/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs b/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs
index 36f55ecd..01a2f6c8 100644
--- a/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs
+++ b/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs
@@ -74,7 +74,12 @@ public async Task ExecuteAsync(IEnumerable requests, Pu
if (metadata.UpdatedAt.HasValue && metadata.UpdatedAt.Value > lastSynchronization)
{
lastSynchronization = metadata.UpdatedAt.Value;
- await DeltaTokenStore.SetDeltaTokenAsync(pullResponse.QueryId, metadata.UpdatedAt.Value, cancellationToken).ConfigureAwait(false);
+ bool isAdded = await DeltaTokenStore.SetDeltaTokenAsync(pullResponse.QueryId, metadata.UpdatedAt.Value, cancellationToken).ConfigureAwait(false);
+ if (isAdded)
+ {
+ // Sqlite oddity - you can't add then update; it changes the change type to UPDATE, which then fails.
+ _ = await context.SaveChangesAsync(true, false, cancellationToken).ConfigureAwait(false);
+ }
}
}
@@ -117,7 +122,7 @@ public async Task ExecuteAsync(IEnumerable requests, Pu
foreach (PullRequest request in requests)
{
DateTimeOffset lastSynchronization = await context.DeltaTokenStore.GetDeltaTokenAsync(request.QueryId, cancellationToken).ConfigureAwait(false);
- PrepareQueryDescription(request.QueryDescription, lastSynchronization);
+ request.QueryDescription = PrepareQueryDescription(request.QueryDescription, lastSynchronization);
serviceRequestQueue.Enqueue(request);
}
@@ -171,10 +176,12 @@ internal async Task> GetPageAsync(HttpClient client, Uri requestUri
///
/// Prepares the query description for use as a pull request.
///
- /// The query description to modify.
+ /// The query description to modify.
/// The last synchronization date/time
- internal static void PrepareQueryDescription(QueryDescription query, DateTimeOffset lastSynchronization)
+ /// A modified query description for the actual query.
+ internal static QueryDescription PrepareQueryDescription(QueryDescription source, DateTimeOffset lastSynchronization)
{
+ QueryDescription query = new(source);
if (lastSynchronization.ToUnixTimeMilliseconds() > 0L)
{
BinaryOperatorNode deltaTokenFilter = new(BinaryOperatorKind.GreaterThan)
@@ -185,12 +192,13 @@ internal static void PrepareQueryDescription(QueryDescription query, DateTimeOff
query.Filter = query.Filter is null ? deltaTokenFilter : new BinaryOperatorNode(BinaryOperatorKind.And, query.Filter, deltaTokenFilter);
}
- query.QueryParameters.Add(ODataQueryParameters.IncludeDeleted, "true");
+ query.QueryParameters[ODataQueryParameters.IncludeDeleted] = "true";
query.RequestTotalCount = true;
query.Top = null;
query.Skip = 0;
query.Ordering.Clear();
query.Ordering.Add(new OrderByNode(new MemberAccessNode(null, "updatedAt"), true));
+ return query;
}
///
diff --git a/src/CommunityToolkit.Datasync.Client/Query/Linq/QueryDescription.cs b/src/CommunityToolkit.Datasync.Client/Query/Linq/QueryDescription.cs
index 6a675d1d..175e7556 100644
--- a/src/CommunityToolkit.Datasync.Client/Query/Linq/QueryDescription.cs
+++ b/src/CommunityToolkit.Datasync.Client/Query/Linq/QueryDescription.cs
@@ -14,6 +14,30 @@ namespace CommunityToolkit.Datasync.Client.Query.Linq;
///
internal class QueryDescription
{
+ ///
+ /// Creates a new blank
+ ///
+ internal QueryDescription()
+ {
+ }
+
+ ///
+ /// Creates a new based on a source .
+ ///
+ /// The source of the
+ internal QueryDescription(QueryDescription source)
+ {
+ Filter = source.Filter; // Note: we don't clone the filter, so you have to be careful to not change any nodes.
+ RequestTotalCount = source.RequestTotalCount;
+ Ordering = [..source.Ordering];
+ ProjectionArgumentType = source.ProjectionArgumentType;
+ Projections = [..source.Projections];
+ QueryParameters = new Dictionary(source.QueryParameters);
+ Selection = new List(source.Selection);
+ Skip = source.Skip;
+ Top = source.Top;
+ }
+
///
/// The for the query filter expression.
///
diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Helpers/IntegrationDbContext.cs b/tests/CommunityToolkit.Datasync.Client.Test/Helpers/IntegrationDbContext.cs
new file mode 100644
index 00000000..0ddea9d8
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Client.Test/Helpers/IntegrationDbContext.cs
@@ -0,0 +1,42 @@
+// 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.TestCommon.Databases;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+namespace CommunityToolkit.Datasync.Client.Test.Helpers;
+
+[ExcludeFromCodeCoverage]
+public class IntegrationDbContext(DbContextOptions options) : OfflineDbContext(options)
+{
+ public DbSet Movies => Set();
+
+ public ServiceApplicationFactory Factory { get; set; }
+
+ public SqliteConnection Connection { get; set; }
+
+ public string Filename { get; set; }
+
+ protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder)
+ {
+ optionsBuilder.UseHttpClient(Factory.CreateClient());
+ optionsBuilder.Entity(cfg =>
+ {
+ cfg.ClientName = "movies";
+ cfg.Endpoint = new Uri($"/{Factory.MovieEndpoint}", UriKind.Relative);
+ });
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ Connection.Close();
+ }
+
+ base.Dispose(disposing);
+ }
+}
diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Helpers/ServiceApplicationFactory.cs b/tests/CommunityToolkit.Datasync.Client.Test/Helpers/ServiceApplicationFactory.cs
new file mode 100644
index 00000000..1353f802
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Client.Test/Helpers/ServiceApplicationFactory.cs
@@ -0,0 +1,98 @@
+// 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.Server.InMemory;
+using CommunityToolkit.Datasync.Server;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using CommunityToolkit.Datasync.TestCommon.Databases;
+using CommunityToolkit.Datasync.TestCommon.Models;
+using System.Linq.Expressions;
+
+namespace CommunityToolkit.Datasync.Client.Test.Helpers;
+
+[ExcludeFromCodeCoverage]
+public class ServiceApplicationFactory : WebApplicationFactory
+{
+ internal string KitchenSinkEndpoint = "api/in-memory/kitchensink";
+ internal string MovieEndpoint = "api/in-memory/movies";
+ internal string PagedMovieEndpoint = "api/in-memory/pagedmovies";
+ internal string SoftDeletedMovieEndpoint = "api/in-memory/softmovies";
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.UseEnvironment("Development");
+ // base.ConfigureWebHost(builder);
+ }
+
+ internal IList GetEntities() where TEntity : InMemoryTableData
+ {
+ using IServiceScope scope = Services.CreateScope();
+ InMemoryRepository repository = scope.ServiceProvider.GetRequiredService>() as InMemoryRepository;
+ return [.. repository.GetEntities()];
+ }
+
+ internal int Count() where TEntity : InMemoryTableData
+ {
+ using IServiceScope scope = Services.CreateScope();
+ InMemoryRepository repository = scope.ServiceProvider.GetRequiredService>() as InMemoryRepository;
+ return repository.GetEntities().Count;
+ }
+
+ internal InMemoryMovie GetRandomMovie()
+ {
+ // Note that we don't use all movies, since some of them are not "valid", which will result in a 400 error instead
+ // of the expected error when testing replace or create functionality.
+ using IServiceScope scope = Services.CreateScope();
+ InMemoryRepository repository = scope.ServiceProvider.GetRequiredService>() as InMemoryRepository;
+ List entities = repository.GetEntities().Where(x => IsValid(x)).ToList();
+ return entities[new Random().Next(entities.Count)];
+ }
+
+ internal TEntity GetServerEntityById(string id) where TEntity : InMemoryTableData
+ {
+ using IServiceScope scope = Services.CreateScope();
+ InMemoryRepository repository = scope.ServiceProvider.GetRequiredService>() as InMemoryRepository;
+ return repository.GetEntity(id);
+ }
+
+ ///
+ /// Checks that the movie is "valid" according to the server.
+ ///
+ /// The movie to check.
+ /// true if valid, false otherwise.
+ internal static bool IsValid(IMovie movie)
+ {
+ return movie.Title.Length >= 2 && movie.Title.Length <= 60
+ && movie.Year >= 1920 && movie.Year <= 2030
+ && movie.Duration >= 60 && movie.Duration <= 360;
+ }
+
+ internal void RunWithRepository(Action> action) where TEntity : InMemoryTableData
+ {
+ using IServiceScope scope = Services.CreateScope();
+ InMemoryRepository repository = scope.ServiceProvider.GetRequiredService>() as InMemoryRepository;
+ action.Invoke(repository);
+ }
+
+ internal void SoftDelete(TEntity entity, bool deleted = true) where TEntity : InMemoryTableData
+ {
+ using IServiceScope scope = Services.CreateScope();
+ InMemoryRepository repository = scope.ServiceProvider.GetRequiredService>() as InMemoryRepository;
+ entity.Deleted = deleted;
+ repository.StoreEntity(entity);
+ }
+
+ internal void SoftDelete(Expression> expression, bool deleted = true) where TEntity : InMemoryTableData
+ {
+ using IServiceScope scope = Services.CreateScope();
+ InMemoryRepository repository = scope.ServiceProvider.GetRequiredService>() as InMemoryRepository;
+ foreach (TEntity entity in repository.GetEntities().Where(expression.Compile()))
+ {
+ entity.Deleted = deleted;
+ repository.StoreEntity(entity);
+ }
+ }
+}
diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Helpers/ServiceTest.cs b/tests/CommunityToolkit.Datasync.Client.Test/Helpers/ServiceTest.cs
new file mode 100644
index 00000000..869bbbe0
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Client.Test/Helpers/ServiceTest.cs
@@ -0,0 +1,123 @@
+// 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.Server.InMemory;
+using CommunityToolkit.Datasync.TestCommon.Databases;
+using CommunityToolkit.Datasync.TestCommon.Models;
+using CommunityToolkit.Datasync.TestCommon.TestData;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Spatial;
+using System.Linq.Expressions;
+
+namespace CommunityToolkit.Datasync.Client.Test.Helpers;
+
+[ExcludeFromCodeCoverage]
+public abstract class ServiceTest(ServiceApplicationFactory factory)
+{
+ protected readonly HttpClient client = factory.CreateClient();
+
+ protected DateTimeOffset StartTime { get; } = DateTimeOffset.UtcNow;
+
+ internal IntegrationDbContext GetOfflineContext(bool useRealFile = false)
+ {
+ string filename = null;
+ string connectionString = "Data Source=:memory:";
+ if (useRealFile)
+ {
+ filename = Path.GetTempFileName();
+ SqliteConnectionStringBuilder builder = new();
+ builder.DataSource = filename;
+ builder.Mode = SqliteOpenMode.ReadWriteCreate;
+ connectionString = builder.ConnectionString;
+ }
+
+ SqliteConnection connection = new(connectionString);
+ connection.Open();
+
+ DbContextOptionsBuilder optionsBuilder = new();
+ optionsBuilder.UseSqlite(connection);
+ optionsBuilder.LogTo(Console.WriteLine);
+ optionsBuilder.EnableSensitiveDataLogging();
+ optionsBuilder.EnableDetailedErrors();
+
+ IntegrationDbContext context = new(optionsBuilder.Options)
+ {
+ Factory = factory,
+ Filename = filename,
+ Connection = connection
+ };
+
+ context.Database.EnsureCreated();
+ return context;
+ }
+
+ internal DatasyncServiceClient GetMovieClient()
+ => new(new Uri($"/{factory.MovieEndpoint}", UriKind.Relative), this.client);
+
+ internal DatasyncServiceClient GetSoftDeletedMovieClient()
+ => new(new Uri($"/{factory.SoftDeletedMovieEndpoint}", UriKind.Relative), this.client);
+
+ internal DatasyncServiceClient GetKitchenSinkClient()
+ => new(new Uri($"/{factory.KitchenSinkEndpoint}", UriKind.Relative), this.client);
+
+ internal int Count() where TEntity : InMemoryTableData
+ => factory.Count();
+
+ internal InMemoryMovie GetRandomMovie()
+ => factory.GetRandomMovie();
+
+ internal TEntity GetServerEntityById(string id) where TEntity : InMemoryTableData
+ => factory.GetServerEntityById(id);
+
+ protected void SeedKitchenSinkWithCountryData()
+ {
+ factory.RunWithRepository(repository =>
+ {
+ repository.Clear();
+ foreach (Country countryRecord in CountryData.GetCountries())
+ {
+ InMemoryKitchenSink model = new()
+ {
+ Id = countryRecord.IsoCode,
+ Version = Guid.NewGuid().ToByteArray(),
+ UpdatedAt = DateTimeOffset.UtcNow,
+ Deleted = false,
+ PointValue = GeographyPoint.Create(countryRecord.Latitude, countryRecord.Longitude),
+ StringValue = countryRecord.CountryName
+ };
+ repository.StoreEntity(model);
+ }
+ });
+ }
+
+ protected void SeedKitchenSinkWithDateTimeData()
+ {
+ factory.RunWithRepository(repository =>
+ {
+ repository.Clear();
+ DateOnly SourceDate = new(2022, 1, 1);
+ for (int i = 0; i < 365; i++)
+ {
+ DateOnly date = SourceDate.AddDays(i);
+ InMemoryKitchenSink model = new()
+ {
+ Id = string.Format("id-{0:000}", i),
+ Version = Guid.NewGuid().ToByteArray(),
+ UpdatedAt = DateTimeOffset.UtcNow,
+ Deleted = false,
+ DateOnlyValue = date,
+ TimeOnlyValue = new TimeOnly(date.Month, date.Day)
+ };
+ repository.StoreEntity(model);
+ }
+ });
+ }
+
+ internal void SoftDelete(TEntity entity, bool deleted = true) where TEntity : InMemoryTableData
+ => factory.SoftDelete(entity, deleted);
+
+ internal void SoftDelete(Expression> expression, bool deleted = true) where TEntity : InMemoryTableData
+ => factory.SoftDelete(expression, deleted);
+}
diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Pull_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Pull_Tests.cs
new file mode 100644
index 00000000..1686dea8
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Pull_Tests.cs
@@ -0,0 +1,107 @@
+// 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.Test.Helpers;
+using CommunityToolkit.Datasync.TestCommon;
+using CommunityToolkit.Datasync.TestCommon.Databases;
+using CommunityToolkit.Datasync.TestCommon.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace CommunityToolkit.Datasync.Client.Test.Offline;
+
+[ExcludeFromCodeCoverage]
+[Collection("SynchronizedOfflineTests")]
+public class Integration_Pull_Tests : ServiceTest, IClassFixture, IDisposable
+{
+ private readonly IntegrationDbContext context;
+
+ #region Setup
+ public Integration_Pull_Tests(ServiceApplicationFactory factory) : base(factory)
+ {
+ this.context = GetOfflineContext();
+ }
+ #endregion
+
+ #region Tear Down
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ this.context.Dispose();
+ }
+ }
+
+ public void Dispose()
+ {
+ // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+ #endregion
+
+ [Fact]
+ public async Task PullAsync_ViaDbSet_Works()
+ {
+ await this.context.Movies.PullAsync();
+ List movies = await this.context.Movies.ToListAsync();
+
+ movies.Count.Should().Be(248);
+ foreach (ClientMovie movie in movies)
+ {
+ InMemoryMovie serviceMovie = GetServerEntityById(movie.Id);
+ serviceMovie.Should().NotBeNull()
+ .And.BeEquivalentTo(serviceMovie)
+ .And.HaveEquivalentMetadataTo(serviceMovie);
+ }
+ }
+
+ [Fact]
+ public async Task PullAsync_ViaContext_Works()
+ {
+ await this.context.PullAsync();
+ List movies = await this.context.Movies.ToListAsync();
+
+ movies.Count.Should().Be(248);
+ foreach (ClientMovie movie in movies)
+ {
+ InMemoryMovie serviceMovie = GetServerEntityById(movie.Id);
+ serviceMovie.Should().NotBeNull()
+ .And.BeEquivalentTo(serviceMovie)
+ .And.HaveEquivalentMetadataTo(serviceMovie);
+ }
+ }
+
+ [Fact]
+ public async Task PullAsync_ViaConfigurator_Works()
+ {
+ await this.context.PullAsync(builder =>
+ {
+ builder.SetParallelOperations(2);
+
+ builder.AddPullRequest(opt =>
+ {
+ opt.QueryId = "pg-rated";
+ opt.Query.Where(x => x.Rating == MovieRating.PG);
+ });
+
+ builder.AddPullRequest(opt =>
+ {
+ opt.QueryId = "pg13-rated";
+ opt.Query.Where(x => x.Rating == MovieRating.PG13);
+ });
+ });
+ List movies = await this.context.Movies.ToListAsync();
+
+ int expectedCount = TestCommon.TestData.Movies.MovieList.Count(x => x.Rating is MovieRating.PG or MovieRating.PG13);
+ movies.Count.Should().Be(expectedCount);
+ foreach (ClientMovie movie in movies)
+ {
+ InMemoryMovie serviceMovie = GetServerEntityById(movie.Id);
+ serviceMovie.Should().NotBeNull()
+ .And.BeEquivalentTo(serviceMovie)
+ .And.HaveEquivalentMetadataTo(serviceMovie);
+ }
+ }
+}
diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Push_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Push_Tests.cs
new file mode 100644
index 00000000..edf1d3cb
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Push_Tests.cs
@@ -0,0 +1,112 @@
+// 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.Test.Helpers;
+using CommunityToolkit.Datasync.TestCommon.Databases;
+using CommunityToolkit.Datasync.TestCommon.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace CommunityToolkit.Datasync.Client.Test.Offline;
+
+[ExcludeFromCodeCoverage]
+[Collection("SynchronizedOfflineTests")]
+public class Integration_Push_Tests : ServiceTest, IClassFixture, IDisposable
+{
+ private readonly IntegrationDbContext context;
+
+ #region Setup
+ public Integration_Push_Tests(ServiceApplicationFactory factory) : base(factory)
+ {
+ this.context = GetOfflineContext();
+ }
+ #endregion
+
+ #region Tear Down
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ this.context.Dispose();
+ }
+ }
+
+ public void Dispose()
+ {
+ // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+ #endregion
+
+ [Fact]
+ public async Task PushAsync_Complex_Situation()
+ {
+ PullResult initialPullResults = await this.context.Movies.PullAsync();
+ initialPullResults.IsSuccessful.Should().BeTrue();
+ initialPullResults.Additions.Should().Be(248);
+ initialPullResults.Deletions.Should().Be(0);
+ initialPullResults.Replacements.Should().Be(0);
+
+ // Let's add some new movies
+ ClientMovie blackPanther = new(TestCommon.TestData.Movies.BlackPanther) { Id = Guid.NewGuid().ToString("N") };
+ this.context.Movies.Add(blackPanther);
+ await this.context.SaveChangesAsync();
+
+ // And remove any movie that matches some criteria
+ List moviesToDelete = await this.context.Movies.Where(x => x.Duration > 180).ToListAsync();
+ this.context.Movies.RemoveRange(moviesToDelete);
+ await this.context.SaveChangesAsync();
+
+ // Then replace all the Unrated movies with a rating of NC17
+ List moviesToReplace = await this.context.Movies.Where(x => x.Rating == MovieRating.Unrated).ToListAsync();
+ moviesToReplace.ForEach(r =>
+ {
+ r.Rating = MovieRating.NC17;
+ r.Title = r.Title.PadLeft('-');
+ this.context.Movies.Update(r);
+ });
+ await this.context.SaveChangesAsync();
+
+ // Check the queue.
+ List operations = await this.context.DatasyncOperationsQueue.ToListAsync();
+ operations.Count.Should().Be(1 + moviesToDelete.Count + moviesToReplace.Count);
+ operations.Count(x => x.Kind is OperationKind.Add).Should().Be(1);
+ operations.Count(x => x.Kind is OperationKind.Delete).Should().Be(moviesToDelete.Count);
+ operations.Count(x => x.Kind is OperationKind.Replace).Should().Be(moviesToReplace.Count);
+
+ // Now push the results and check what we did
+ PushResult pushResults = await this.context.Movies.PushAsync();
+
+ // This little snippet of code is to aid debugging if this test fails
+ if (!pushResults.IsSuccessful)
+ {
+ foreach (KeyValuePair failedRequest in pushResults.FailedRequests)
+ {
+ string id = failedRequest.Key;
+ ServiceResponse response = failedRequest.Value;
+ string jsonContent = string.Empty;
+ if (response.HasContent)
+ {
+ using StreamReader reader = new(response.ContentStream);
+ jsonContent = reader.ReadToEnd();
+ }
+
+ Console.WriteLine($"FAILED REQUEST FOR ID: {id}: {response.StatusCode}\n{jsonContent}");
+ }
+ }
+
+ pushResults.IsSuccessful.Should().BeTrue();
+ pushResults.CompletedOperations.Should().Be(1 + moviesToDelete.Count + moviesToReplace.Count);
+ this.context.DatasyncOperationsQueue.Should().BeEmpty();
+
+ // Now use PullAsync() again - these should all be pulled down again
+ PullResult pullResults = await this.context.PullAsync();
+ pullResults.IsSuccessful.Should().BeTrue();
+ pullResults.Additions.Should().Be(0);
+ pullResults.Deletions.Should().Be(0);
+ // The service always replaces additions and replacements - updating the last updatedAt.
+ pullResults.Replacements.Should().Be(moviesToReplace.Count + 1);
+ }
+}
diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Operations/PullOperationManager_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Operations/PullOperationManager_Tests.cs
index b7ab8608..7fd0b1e3 100644
--- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Operations/PullOperationManager_Tests.cs
+++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Operations/PullOperationManager_Tests.cs
@@ -183,12 +183,14 @@ public void PrepareQueryDescription_NQ_NS()
{
DatasyncPullQuery query = new();
QueryDescription qd = new QueryTranslator(query).Translate();
+ string expected = qd.ToODataQueryString();
DateTimeOffset lastSynchronization = DateTimeOffset.FromUnixTimeSeconds(0L);
+ QueryDescription actualQD = PullOperationManager.PrepareQueryDescription(qd, lastSynchronization);
- PullOperationManager.PrepareQueryDescription(qd, lastSynchronization);
- string actual = Uri.UnescapeDataString(qd.ToODataQueryString());
+ string actual = Uri.UnescapeDataString(actualQD.ToODataQueryString());
actual.Should().Be("$orderby=updatedAt&$count=true&__includedeleted=true");
+ qd.ToODataQueryString().Should().Be(expected);
}
[Fact]
@@ -197,12 +199,14 @@ public void PrepareQueryDescription_Q_NS()
DatasyncPullQuery query = new();
query.Where(x => x.Rating == TestCommon.Models.MovieRating.G);
QueryDescription qd = new QueryTranslator(query).Translate();
+ string expected = qd.ToODataQueryString();
DateTimeOffset lastSynchronization = DateTimeOffset.FromUnixTimeSeconds(0L);
+ QueryDescription actualQD = PullOperationManager.PrepareQueryDescription(qd, lastSynchronization);
- PullOperationManager.PrepareQueryDescription(qd, lastSynchronization);
- string actual = Uri.UnescapeDataString(qd.ToODataQueryString());
+ string actual = Uri.UnescapeDataString(actualQD.ToODataQueryString());
actual.Should().Be("$filter=(rating eq 'G')&$orderby=updatedAt&$count=true&__includedeleted=true");
+ qd.ToODataQueryString().Should().Be(expected);
}
[Fact]
@@ -210,12 +214,14 @@ public void PrepareQueryDescription_NQ_S()
{
DatasyncPullQuery query = new();
QueryDescription qd = new QueryTranslator(query).Translate();
+ string expected = qd.ToODataQueryString();
DateTimeOffset lastSynchronization = DateTimeOffset.FromUnixTimeMilliseconds(1724444574291L);
+ QueryDescription actualQD = PullOperationManager.PrepareQueryDescription(qd, lastSynchronization);
- PullOperationManager.PrepareQueryDescription(qd, lastSynchronization);
- string actual = Uri.UnescapeDataString(qd.ToODataQueryString());
+ string actual = Uri.UnescapeDataString(actualQD.ToODataQueryString());
actual.Should().Be("$filter=(updatedAt gt cast(2024-08-23T20:22:54.291Z,Edm.DateTimeOffset))&$orderby=updatedAt&$count=true&__includedeleted=true");
+ qd.ToODataQueryString().Should().Be(expected);
}
[Fact]
@@ -224,12 +230,14 @@ public void PrepareQueryDescription_Q_S()
DatasyncPullQuery query = new();
query.Where(x => x.Rating == TestCommon.Models.MovieRating.G);
QueryDescription qd = new QueryTranslator(query).Translate();
+ string expected = qd.ToODataQueryString();
DateTimeOffset lastSynchronization = DateTimeOffset.FromUnixTimeMilliseconds(1724444574291L);
+ QueryDescription actualQD = PullOperationManager.PrepareQueryDescription(qd, lastSynchronization);
- PullOperationManager.PrepareQueryDescription(qd, lastSynchronization);
- string actual = Uri.UnescapeDataString(qd.ToODataQueryString());
+ string actual = Uri.UnescapeDataString(actualQD.ToODataQueryString());
actual.Should().Be("$filter=((rating eq 'G') and (updatedAt gt cast(2024-08-23T20:22:54.291Z,Edm.DateTimeOffset)))&$orderby=updatedAt&$count=true&__includedeleted=true");
+ qd.ToODataQueryString().Should().Be(expected);
}
#endregion
}
diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs
index 088bf5dd..b8c8bb49 100644
--- a/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs
+++ b/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs
@@ -9,7 +9,6 @@
#pragma warning disable IDE0028 // Simplify collection initialization
using CommunityToolkit.Datasync.Client.Http;
-using CommunityToolkit.Datasync.Client.Offline.Operations;
using CommunityToolkit.Datasync.Client.Serialization;
using CommunityToolkit.Datasync.Client.Test.Helpers;
using CommunityToolkit.Datasync.TestCommon;
diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Service/Integration_Query_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Service/Integration_Query_Tests.cs
new file mode 100644
index 00000000..bb20aee7
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Client.Test/Service/Integration_Query_Tests.cs
@@ -0,0 +1,613 @@
+// 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.Test.Helpers;
+using CommunityToolkit.Datasync.TestCommon.Databases;
+using CommunityToolkit.Datasync.TestCommon.Models;
+
+namespace CommunityToolkit.Datasync.Client.Test.Service;
+
+///
+/// A set of tests that use the online client and an actual server
+///
+///
+[ExcludeFromCodeCoverage]
+[Collection("SynchronizedOfflineTests")]
+public class Integration_Query_Tests(ServiceApplicationFactory factory) : ServiceTest(factory), IClassFixture
+{
+ [Fact]
+ public async Task Query_Test_001()
+ {
+ await MovieQueryTest(
+ x => x,
+ Count(),
+ ["id-000", "id-001", "id-002", "id-003", "id-004"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_002()
+ {
+ await MovieQueryTest(
+ x => x.IncludeTotalCount(),
+ Count(),
+ ["id-000", "id-001", "id-002", "id-003", "id-004"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_003()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.Year / 1000.5 == 2 && m.Rating == MovieRating.R),
+ 2,
+ ["id-061", "id-173"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_004()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.Year - 1900 >= 80 && m.Year + 10 < 2000 && m.Duration < 120),
+ 12,
+ ["id-026", "id-047", "id-081", "id-103", "id-121"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_005()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.Year / 1000.5 == 2),
+ 6,
+ ["id-012", "id-042", "id-061", "id-173", "id-194"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_006()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => (m.Year >= 1930 && m.Year <= 1940) || (m.Year >= 1950 && m.Year <= 1960)),
+ 46,
+ ["id-005", "id-016", "id-027", "id-028", "id-031"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_007()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.Year - 1900 >= 80),
+ 138,
+ ["id-000", "id-003", "id-006", "id-007", "id-008"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_008()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => !m.BestPictureWinner),
+ 210,
+ ["id-000", "id-003", "id-004", "id-005", "id-006"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_009()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.BestPictureWinner && Math.Ceiling(m.Duration / 60.0) == 2),
+ 11,
+ ["id-023", "id-024", "id-112", "id-135", "id-142"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_010()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.BestPictureWinner && Math.Floor(m.Duration / 60.0) == 2),
+ 21,
+ ["id-001", "id-011", "id-018", "id-048", "id-051"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_011()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.BestPictureWinner && Math.Round(m.Duration / 60.0) == 2),
+ 24,
+ ["id-011", "id-018", "id-023", "id-024", "id-048"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_012()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.BestPictureWinner),
+ 38,
+ ["id-001", "id-002", "id-007", "id-008", "id-011"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_013()
+ {
+ bool expected = false;
+ await MovieQueryTest(
+ x => x.Where(m => m.BestPictureWinner != expected),
+ 38,
+ ["id-001", "id-002", "id-007", "id-008", "id-011"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_016()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.ReleaseDate.Day == 1),
+ 7,
+ ["id-019", "id-048", "id-129", "id-131", "id-132"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_017()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.Duration >= 60),
+ Count(),
+ ["id-000", "id-001", "id-002", "id-003", "id-004"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_018()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.Title.EndsWith("er")),
+ 12,
+ ["id-001", "id-052", "id-121", "id-130", "id-164"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_019()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.Title.ToLowerInvariant().EndsWith("er")),
+ 12,
+ ["id-001", "id-052", "id-121", "id-130", "id-164"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_020()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.Title.ToUpperInvariant().EndsWith("ER")),
+ 12,
+ ["id-001", "id-052", "id-121", "id-130", "id-164"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_022()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.ReleaseDate.Month == 11),
+ 14,
+ ["id-011", "id-016", "id-030", "id-064", "id-085"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_024()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => !(m.BestPictureWinner == true)),
+ 210,
+ ["id-000", "id-003", "id-004", "id-005", "id-006"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_027()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.Rating == MovieRating.R),
+ 95,
+ ["id-000", "id-001", "id-002", "id-003", "id-007"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_028()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.Rating != MovieRating.PG13),
+ 220,
+ ["id-000", "id-001", "id-002", "id-003", "id-004"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_029()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.Rating == MovieRating.Unrated),
+ 74,
+ ["id-004", "id-005", "id-011", "id-016", "id-031"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_030()
+ {
+ DateOnly comparison = new(1994, 10, 14);
+ await MovieQueryTest(
+ x => x.Where(m => m.ReleaseDate == comparison),
+ 2,
+ ["id-000", "id-003"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_031()
+ {
+ DateOnly comparison = new(1999, 12, 31);
+ await MovieQueryTest(
+ x => x.Where(m => m.ReleaseDate >= comparison),
+ 69,
+ ["id-006", "id-008", "id-012", "id-013", "id-019"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_032()
+ {
+ DateOnly comparison = new(1999, 12, 31);
+ await MovieQueryTest(
+ x => x.Where(m => m.ReleaseDate > comparison),
+ 69,
+ ["id-006", "id-008", "id-012", "id-013", "id-019"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_033()
+ {
+ DateOnly comparison = new(2000, 1, 1);
+ await MovieQueryTest(
+ x => x.Where(m => m.ReleaseDate <= comparison),
+ 179,
+ ["id-000", "id-001", "id-002", "id-003", "id-004"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_034()
+ {
+ DateOnly comparison = new(2000, 1, 1);
+ await MovieQueryTest(
+ x => x.Where(m => m.ReleaseDate < comparison),
+ 179,
+ ["id-000", "id-001", "id-002", "id-003", "id-004"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_035()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => Math.Round(m.Duration / 60.0) == 2.0),
+ TestCommon.TestData.Movies.MovieList.Count(x => Math.Round(x.Duration / 60.0) == 2.0),
+ ["id-000", "id-005", "id-009", "id-010", "id-011"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_037()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.Title.StartsWith("the", StringComparison.InvariantCultureIgnoreCase)),
+ 63,
+ ["id-000", "id-001", "id-002", "id-004", "id-006"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_039()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.Year == 1994),
+ 5,
+ ["id-000", "id-003", "id-018", "id-030", "id-079"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_040()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.Year >= 2000).Where(m => m.Year <= 2009),
+ 55,
+ ["id-006", "id-008", "id-012", "id-019", "id-020"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_046()
+ {
+ await MovieQueryTest(
+ x => x.Where(m => m.ReleaseDate.Year == 1994),
+ 6,
+ ["id-000", "id-003", "id-018", "id-030", "id-079"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_047()
+ {
+ await MovieQueryTest(
+ x => x.OrderBy(m => m.BestPictureWinner),
+ Count(),
+ ["id-000", "id-003", "id-004", "id-005", "id-006"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_048()
+ {
+ await MovieQueryTest(
+ x => x.OrderByDescending(m => m.BestPictureWinner),
+ Count(),
+ ["id-001", "id-002", "id-007", "id-008", "id-011"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_049()
+ {
+ await MovieQueryTest(
+ x => x.OrderBy(m => m.Duration),
+ Count(),
+ ["id-227", "id-125", "id-133", "id-107", "id-118"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_050()
+ {
+ await MovieQueryTest(
+ x => x.OrderByDescending(m => m.Duration),
+ Count(),
+ ["id-153", "id-065", "id-165", "id-008", "id-002"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_059()
+ {
+ await MovieQueryTest(
+ x => x.OrderBy(m => m.Year).ThenByDescending(m => m.Title),
+ Count(),
+ ["id-125", "id-229", "id-133", "id-227", "id-118"]
+ );
+ }
+
+ [Fact]
+ public async Task Query_Test_061()
+ {
+ await MovieQueryTest(
+ x => x.OrderByDescending(m => m.Year).ThenBy(m => m.Title),
+ Count(),
+ ["id-188", "id-122", "id-033", "id-102", "id-213"]
+ );
+ }
+
+ [Fact]
+ public async Task SoftDeleteQueryTest_002()
+ {
+ SoftDelete(x => x.Rating == MovieRating.R);
+ await SoftDeletedMovieQueryTest(
+ x => x.IncludeTotalCount(),
+ 153,
+ ["id-004", "id-005", "id-006", "id-008", "id-010"]
+ );
+ }
+
+ [Fact]
+ public async Task SoftDeleteQueryTest_003()
+ {
+ SoftDelete(x => x.Rating == MovieRating.R);
+ await SoftDeletedMovieQueryTest(
+ x => x.IncludeDeletedItems().Where(m => !m.Deleted),
+ 153,
+ ["id-004", "id-005", "id-006", "id-008", "id-010"]
+ );
+ }
+
+ [Fact]
+ public async Task SoftDeleteQueryTest_004()
+ {
+ SoftDelete(x => x.Rating == MovieRating.R);
+ await MovieQueryTest(
+ x => x.IncludeDeletedItems().Where(m => m.Deleted),
+ 95,
+ ["id-000", "id-001", "id-002", "id-003", "id-007"]
+ );
+ }
+
+ [Fact]
+ public async Task KitchSinkQueryTest_010()
+ {
+ SeedKitchenSinkWithDateTimeData();
+ await KitchenSinkQueryTest(
+ x => x.Where(m => m.TimeOnlyValue.Second == 0).OrderBy(m => m.Id),
+ 365,
+ ["id-000", "id-001", "id-002", "id-003", "id-004", "id-005"]
+ );
+ }
+
+ [Fact]
+ public async Task KitchSinkQueryTest_011()
+ {
+ SeedKitchenSinkWithDateTimeData();
+ await KitchenSinkQueryTest(
+ x => x.Where(m => m.TimeOnlyValue.Hour == 3).OrderBy(m => m.Id),
+ 31,
+ ["id-059", "id-060", "id-061", "id-062", "id-063", "id-064"]
+ );
+ }
+
+ [Fact]
+ public async Task KitchSinkQueryTest_012()
+ {
+ SeedKitchenSinkWithDateTimeData();
+ await KitchenSinkQueryTest(
+ x => x.Where(m => m.TimeOnlyValue.Minute == 21).OrderBy(m => m.Id),
+ 12,
+ ["id-020", "id-051", "id-079", "id-110", "id-140", "id-171"]
+ );
+ }
+
+ [Fact]
+ public async Task KitchSinkQueryTest_014()
+ {
+ SeedKitchenSinkWithDateTimeData();
+ TimeOnly comparison = new(2, 14, 0);
+ await KitchenSinkQueryTest(
+ x => x.Where(m => m.TimeOnlyValue == comparison),
+ 1,
+ ["id-044"]
+
+ );
+ }
+
+ [Fact]
+ public async Task KitchSinkQueryTest_015()
+ {
+ SeedKitchenSinkWithDateTimeData();
+ TimeOnly comparison = new(2, 15, 0);
+ await KitchenSinkQueryTest(
+ x => x.Where(m => m.TimeOnlyValue >= comparison).OrderBy(m => m.Id),
+ 320,
+ ["id-045", "id-046", "id-047", "id-048"]
+ );
+ }
+
+ [Fact]
+ public async Task KitchSinkQueryTest_016()
+ {
+ SeedKitchenSinkWithDateTimeData();
+ TimeOnly comparison = new(2, 15, 0);
+ await KitchenSinkQueryTest(
+ x => x.Where(m => m.TimeOnlyValue > comparison).OrderBy(m => m.Id),
+ 319,
+ ["id-046", "id-047", "id-048", "id-049"]
+ );
+ }
+
+ [Fact]
+ public async Task KitchSinkQueryTest_017()
+ {
+ SeedKitchenSinkWithDateTimeData();
+ TimeOnly comparison = new(7, 14, 0);
+ await KitchenSinkQueryTest(
+ x => x.Where(m => m.TimeOnlyValue <= comparison).OrderBy(m => m.Id),
+ 195,
+ ["id-000", "id-001", "id-002", "id-003"]
+ );
+ }
+
+ //[Fact]
+ //public async Task KitchenSinkQueryTest_019()
+ //{
+ // SeedKitchenSinkWithCountryData();
+ // await KitchenSinkQueryTest(
+ // $"{this.factory.KitchenSinkEndpoint}?$filter=geo.distance(pointValue, geography'POINT(-97 38)') lt 0.2",
+ // 1,
+ // null,
+ // null,
+ // ["US"]
+ // );
+ //}
+
+ [Fact]
+ public async Task KitchenSinkQueryTest_020()
+ {
+ SeedKitchenSinkWithCountryData();
+ string[] comparison = ["IT", "GR", "EG"];
+ await KitchenSinkQueryTest(
+ x => x.Where(m => comparison.Contains(m.Id)).OrderBy(m => m.Id),
+ 3,
+ ["EG", "GR", "IT"]
+ );
+ }
+
+ #region Base Tests
+ private async Task MovieQueryTest(
+ Func, IDatasyncQueryable> query,
+ int itemCount,
+ string[] firstItems)
+ {
+ DatasyncServiceClient client = GetMovieClient();
+
+ IDatasyncQueryable executableQuery = query.Invoke(client.AsQueryable());
+ List results = await executableQuery.ToListAsync();
+
+ results.Count.Should().Be(itemCount);
+ results.Take(firstItems.Length).Select(m => m.Id).Should().BeEquivalentTo(firstItems);
+ foreach (ClientMovie item in results)
+ {
+ InMemoryMovie expected = GetServerEntityById(item.Id)!;
+ item.Should().BeEquivalentTo(expected);
+ }
+ }
+
+ private async Task SoftDeletedMovieQueryTest(
+ Func, IDatasyncQueryable> query,
+ int itemCount,
+ string[] firstItems)
+ {
+ DatasyncServiceClient client = GetSoftDeletedMovieClient();
+
+ IDatasyncQueryable executableQuery = query.Invoke(client.AsQueryable());
+ List results = await executableQuery.ToListAsync();
+
+ results.Count.Should().Be(itemCount);
+ results.Take(firstItems.Length).Select(m => m.Id).Should().BeEquivalentTo(firstItems);
+ foreach (ClientMovie item in results)
+ {
+ InMemoryMovie expected = GetServerEntityById(item.Id)!;
+ item.Should().BeEquivalentTo(expected);
+ }
+ }
+
+ private async Task KitchenSinkQueryTest(
+ Func, IDatasyncQueryable> query,
+ int itemCount,
+ string[] firstItems)
+ {
+ DatasyncServiceClient client = GetKitchenSinkClient();
+
+ IDatasyncQueryable executableQuery = query.Invoke(client.AsQueryable());
+ List results = await executableQuery.ToListAsync();
+
+ results.Count.Should().Be(itemCount);
+ results.Take(firstItems.Length).Select(m => m.Id).Should().BeEquivalentTo(firstItems);
+ foreach (ClientKitchenSink item in results)
+ {
+ InMemoryKitchenSink expected = GetServerEntityById(item.Id)!;
+ item.Should().BeEquivalentTo(expected);
+ }
+ }
+ #endregion
+}
diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Service/Integration_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Service/Integration_Tests.cs
new file mode 100644
index 00000000..85f56c4d
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Client.Test/Service/Integration_Tests.cs
@@ -0,0 +1,394 @@
+// 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.Test.Helpers;
+using CommunityToolkit.Datasync.TestCommon;
+using CommunityToolkit.Datasync.TestCommon.Databases;
+using CommunityToolkit.Datasync.TestCommon.Models;
+using Microsoft.Spatial;
+using TestData = CommunityToolkit.Datasync.TestCommon.TestData;
+
+namespace CommunityToolkit.Datasync.Client.Test.Service;
+
+///
+/// A set of tests that use the online client and an actual server
+///
+///
+[ExcludeFromCodeCoverage]
+[Collection("SynchronizedOfflineTests")]
+public class Integration_Tests(ServiceApplicationFactory factory) : ServiceTest(factory), IClassFixture
+{
+ #region AddAsync
+ [Theory]
+ [InlineData(null)]
+ [InlineData("de76a422-7fb0-4f1f-9bb4-12b3c7882541")]
+ public async Task Create_WithValidInput_Returns201(string id)
+ {
+ ClientMovie source = new(TestData.Movies.BlackPanther) { Id = id };
+ DatasyncServiceClient client = GetMovieClient();
+
+ ServiceResponse response = await client.AddAsync(source);
+ InMemoryMovie inMemoryMovie = GetServerEntityById(response.Value.Id);
+
+ response.IsSuccessful.Should().BeTrue();
+ response.StatusCode.Should().Be(201);
+ response.HasValue.Should().BeTrue();
+
+ response.Value.Should().NotBeNull().And.HaveChangedMetadata(id, StartTime).And.BeEquivalentTo(source);
+ response.Value.Should().HaveEquivalentMetadataTo(inMemoryMovie).And.BeEquivalentTo(inMemoryMovie);
+ response.Headers.Should().Contain("ETag", $"\"{response.Value.Version}\"");
+ }
+
+ [Fact]
+ public async Task Create_ExistingId_Returns409()
+ {
+ InMemoryMovie existingMovie = GetRandomMovie();
+ ClientMovie source = new(TestData.Movies.BlackPanther) { Id = existingMovie.Id };
+ DatasyncServiceClient client = GetMovieClient();
+
+ Func act = async () => _ = await client.AddAsync(source);
+ ConflictException ex = (await act.Should().ThrowAsync>()).Subject.First();
+
+ ex.ServiceResponse.IsSuccessful.Should().BeFalse();
+ ex.ServiceResponse.IsConflictStatusCode.Should().BeTrue();
+ ex.ServiceResponse.StatusCode.Should().Be(409);
+
+ ex.ClientEntity.Should().BeEquivalentTo(source);
+ ex.ServerEntity.Should().NotBeNull().And.HaveEquivalentMetadataTo(existingMovie).And.BeEquivalentTo(existingMovie);
+ }
+
+ [Fact]
+ public async Task Create_SoftDeleted_Returns409()
+ {
+ InMemoryMovie existingMovie = GetRandomMovie();
+ SoftDelete(existingMovie);
+ ClientMovie source = new(TestData.Movies.BlackPanther) { Id = existingMovie.Id };
+ DatasyncServiceClient client = GetMovieClient();
+
+ Func act = async () => _ = await client.AddAsync(source);
+ ConflictException ex = (await act.Should().ThrowAsync>()).Subject.First();
+
+ ex.ServiceResponse.IsSuccessful.Should().BeFalse();
+ ex.ServiceResponse.IsConflictStatusCode.Should().BeTrue();
+ ex.ServiceResponse.StatusCode.Should().Be(409);
+
+ ex.ClientEntity.Should().BeEquivalentTo(source);
+ ex.ServerEntity.Should().NotBeNull().And.HaveEquivalentMetadataTo(existingMovie).And.BeEquivalentTo(existingMovie);
+ }
+
+ [Fact]
+ public async Task Create_CanRoundtrip_Types()
+ {
+ const string id = "ks01";
+ ClientKitchenSink source = new()
+ {
+ Id = id,
+ StringValue = "state=none",
+ EnumValue = KitchenSinkState.None,
+ DateOnlyValue = new DateOnly(2023, 12, 15),
+ TimeOnlyValue = new TimeOnly(9, 52, 35),
+ PointValue = GeographyPoint.Create(-122.333056, 47.609722)
+ };
+ DatasyncServiceClient client = GetKitchenSinkClient();
+
+ ServiceResponse response = await client.AddAsync(source);
+ InMemoryKitchenSink serverEntity = GetServerEntityById(id);
+
+ response.IsSuccessful.Should().BeTrue();
+ response.StatusCode.Should().Be(201);
+ response.HasValue.Should().BeTrue();
+
+ response.Value.Should().NotBeNull().And.HaveChangedMetadata(id, StartTime).And.BeEquivalentTo(source);
+ serverEntity.Should().BeEquivalentTo(source);
+ }
+ #endregion
+
+ #region GetAsync
+ [Fact]
+ public async Task Read_Returns200()
+ {
+ InMemoryMovie existingMovie = GetRandomMovie();
+ DatasyncServiceClient client = GetMovieClient();
+
+ ServiceResponse response = await client.GetAsync(existingMovie.Id);
+
+ response.IsSuccessful.Should().BeTrue();
+ response.StatusCode.Should().Be(200);
+ response.HasValue.Should().BeTrue();
+
+ response.Value.Should().HaveEquivalentMetadataTo(existingMovie).And.BeEquivalentTo(existingMovie);
+ response.Headers.Should().Contain("ETag", $"\"{response.Value.Version}\"");
+ }
+
+ [Fact]
+ public async Task Read_MissingId_Returns404()
+ {
+ DatasyncServiceClient client = GetMovieClient();
+ DatasyncServiceOptions options = new() { ThrowIfMissing = false };
+
+ ServiceResponse response = await client.GetAsync("id-is-missing", options);
+
+ response.IsSuccessful.Should().BeFalse();
+ response.StatusCode.Should().Be(404);
+ }
+
+ [Fact]
+ public async Task Read_MissingId_Returns404_ThrowIfMissing()
+ {
+ DatasyncServiceClient client = GetMovieClient();
+ DatasyncServiceOptions options = new() { ThrowIfMissing = true };
+
+ Func act = async () => _ = await client.GetAsync("id-is-missing", options);
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task Read_SoftDeleted_NotDeleted_Returns200()
+ {
+ InMemoryMovie existingMovie = GetRandomMovie();
+ DatasyncServiceClient client = GetSoftDeletedMovieClient();
+
+ ServiceResponse response = await client.GetAsync(existingMovie.Id);
+
+ response.IsSuccessful.Should().BeTrue();
+ response.StatusCode.Should().Be(200);
+ response.HasValue.Should().BeTrue();
+
+ response.Value.Should().HaveEquivalentMetadataTo(existingMovie).And.BeEquivalentTo(existingMovie);
+ response.Headers.Should().Contain("ETag", $"\"{response.Value.Version}\"");
+ }
+
+ [Fact]
+ public async Task Read_SoftDeleted_Deleted_Returns404()
+ {
+ InMemoryMovie existingMovie = GetRandomMovie();
+ SoftDelete(existingMovie);
+
+ DatasyncServiceClient client = GetMovieClient();
+ DatasyncServiceOptions options = new() { ThrowIfMissing = true };
+
+ Func act = async () => _ = await client.GetAsync("id-is-missing", options);
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task Read_SoftDeleted_Deleted_WithIncludeDeleted_Returns200()
+ {
+ InMemoryMovie existingMovie = GetRandomMovie();
+ SoftDelete(existingMovie);
+ DatasyncServiceClient client = GetSoftDeletedMovieClient();
+ DatasyncServiceOptions options = new() { IncludeDeleted = true };
+
+ ServiceResponse response = await client.GetAsync(existingMovie.Id, options);
+
+ response.IsSuccessful.Should().BeTrue();
+ response.StatusCode.Should().Be(200);
+ response.HasValue.Should().BeTrue();
+
+ response.Value.Should().HaveEquivalentMetadataTo(existingMovie).And.BeEquivalentTo(existingMovie);
+ response.Headers.Should().Contain("ETag", $"\"{response.Value.Version}\"");
+ }
+ #endregion
+
+ #region RemoveAsync
+ [Fact]
+ public async Task Delete_ById_Returns204()
+ {
+ InMemoryMovie existingMovie = GetRandomMovie();
+ DatasyncServiceClient client = GetMovieClient();
+
+ ServiceResponse response = await client.RemoveAsync(existingMovie.Id, new DatasyncServiceOptions());
+
+ response.IsSuccessful.Should().BeTrue();
+ response.StatusCode.Should().Be(204);
+ response.HasContent.Should().BeFalse();
+
+ InMemoryMovie serverEntity = GetServerEntityById(existingMovie.Id);
+ serverEntity.Should().BeNull();
+
+ }
+
+ [Fact]
+ public async Task Delete_WithVersioning_Works()
+ {
+ InMemoryMovie existingMovie = GetRandomMovie();
+ string version = Convert.ToBase64String(existingMovie.Version);
+ DatasyncServiceClient client = GetMovieClient();
+ DatasyncServiceOptions options = new() { Version = version };
+
+ ServiceResponse response = await client.RemoveAsync(existingMovie.Id, options);
+
+ response.IsSuccessful.Should().BeTrue();
+ response.StatusCode.Should().Be(204);
+ response.HasContent.Should().BeFalse();
+
+ InMemoryMovie serverEntity = GetServerEntityById(existingMovie.Id);
+ serverEntity.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task Delete_WithVersioning_Conflict()
+ {
+ InMemoryMovie existingMovie = GetRandomMovie();
+ string version = "dGVzdA==";
+ DatasyncServiceClient client = GetMovieClient();
+ DatasyncServiceOptions options = new() { Version = version };
+
+ Func act = async () => _ = await client.RemoveAsync(existingMovie.Id, options);
+ ConflictException ex = (await act.Should().ThrowAsync>()).Subject.First();
+
+ ex.ServiceResponse.IsSuccessful.Should().BeFalse();
+ ex.ServiceResponse.IsConflictStatusCode.Should().BeTrue();
+ ex.ServiceResponse.StatusCode.Should().Be(412);
+
+ ex.ServerEntity.Should().NotBeNull().And.HaveEquivalentMetadataTo(existingMovie).And.BeEquivalentTo(existingMovie);
+
+ InMemoryMovie serverEntity = GetServerEntityById(existingMovie.Id);
+ serverEntity.Should().NotBeNull().And.BeEquivalentTo(existingMovie);
+ }
+
+ [Fact]
+ public async Task Delete_MissingId_Returns404()
+ {
+ DatasyncServiceClient client = GetMovieClient();
+ DatasyncServiceOptions options = new() { ThrowIfMissing = false };
+
+ ServiceResponse response = await client.RemoveAsync("id-is-missing", options);
+
+ response.IsSuccessful.Should().BeFalse();
+ response.StatusCode.Should().Be(404);
+ }
+
+ [Fact]
+ public async Task Delete_MissingId_Returns404_ThrowIfMissing()
+ {
+ DatasyncServiceClient client = GetMovieClient();
+ DatasyncServiceOptions options = new() { ThrowIfMissing = true };
+
+ Func act = async () => _ = await client.RemoveAsync("id-is-missing", options);
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task Delete_NotSoftDeleted_Returns204()
+ {
+ InMemoryMovie existingMovie = GetRandomMovie();
+ byte[] existingVersion = [.. existingMovie.Version];
+ DateTimeOffset existingUpdatedAt = (DateTimeOffset)existingMovie.UpdatedAt;
+ DatasyncServiceClient client = GetSoftDeletedMovieClient();
+ DatasyncServiceOptions options = new();
+
+ ServiceResponse response = await client.RemoveAsync(existingMovie.Id, options);
+
+ response.IsSuccessful.Should().BeTrue();
+ response.StatusCode.Should().Be(204);
+ response.HasContent.Should().BeFalse();
+
+ InMemoryMovie serverEntity = GetServerEntityById(existingMovie.Id);
+ serverEntity.Should().NotBeNull();
+ serverEntity.UpdatedAt.Should().NotBe(existingUpdatedAt).And.BeAfter(StartTime).And.BeBefore(DateTimeOffset.UtcNow);
+ serverEntity.Version.Should().NotBeEquivalentTo(existingVersion);
+ serverEntity.Deleted.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Delete_SoftDeletedId_Returns410()
+ {
+ InMemoryMovie existingMovie = GetRandomMovie();
+ SoftDelete(existingMovie);
+ DatasyncServiceClient client = GetSoftDeletedMovieClient();
+ DatasyncServiceOptions options = new() { ThrowIfMissing = false };
+
+ Func act = async () => _ = await client.RemoveAsync(existingMovie.Id, options);
+ (await act.Should().ThrowAsync()).Which.ServiceResponse.StatusCode.Should().Be(410);
+
+ InMemoryMovie serverEntity = GetServerEntityById(existingMovie.Id);
+ serverEntity.Should().NotBeNull();
+ }
+ #endregion
+
+ #region ReplaceAsync
+ [Fact]
+ public async Task Replace_Returns200()
+ {
+ ClientMovie existingMovie = new(GetRandomMovie()) { Title = "New Title" };
+ DatasyncServiceClient client = GetMovieClient();
+ DatasyncServiceOptions options = new();
+
+ ServiceResponse response = await client.ReplaceAsync(existingMovie, options);
+ InMemoryMovie inMemoryMovie = GetServerEntityById(response.Value.Id);
+
+ response.IsSuccessful.Should().BeTrue();
+ response.StatusCode.Should().Be(200);
+ response.HasValue.Should().BeTrue();
+
+ response.Value.Should().NotBeNull().And.HaveChangedMetadata(existingMovie.Id, StartTime).And.BeEquivalentTo(existingMovie);
+ response.Value.Should().HaveEquivalentMetadataTo(inMemoryMovie).And.BeEquivalentTo(inMemoryMovie);
+ response.Headers.Should().Contain("ETag", $"\"{response.Value.Version}\"");
+ }
+
+ [Fact]
+ public async Task Replace_WithVersioning_Works()
+ {
+ ClientMovie existingMovie = new(GetRandomMovie()) { Title = "New Title" };
+ DatasyncServiceClient client = GetMovieClient();
+
+ ServiceResponse response = await client.ReplaceAsync(existingMovie);
+ InMemoryMovie inMemoryMovie = GetServerEntityById(response.Value.Id);
+
+ response.IsSuccessful.Should().BeTrue();
+ response.StatusCode.Should().Be(200);
+ response.HasValue.Should().BeTrue();
+
+ response.Value.Should().NotBeNull().And.HaveChangedMetadata(existingMovie.Id, StartTime).And.BeEquivalentTo(existingMovie);
+ response.Value.Should().HaveEquivalentMetadataTo(inMemoryMovie).And.BeEquivalentTo(inMemoryMovie);
+ response.Headers.Should().Contain("ETag", $"\"{response.Value.Version}\"");
+ }
+
+ [Fact]
+ public async Task Replace_WithVersioning_Conflict()
+ {
+ ClientMovie existingMovie = new(GetRandomMovie()) { Title = "New Title" };
+ DatasyncServiceClient client = GetMovieClient();
+ DatasyncServiceOptions options = new() { Version = "dGVzdA==" };
+ InMemoryMovie serverEntity = GetServerEntityById(existingMovie.Id);
+
+ Func act = async () => _ = await client.ReplaceAsync(existingMovie, options);
+ ConflictException ex = (await act.Should().ThrowAsync>()).Subject.First();
+
+ ex.ServiceResponse.IsSuccessful.Should().BeFalse();
+ ex.ServiceResponse.IsConflictStatusCode.Should().BeTrue();
+ ex.ServiceResponse.StatusCode.Should().Be(412);
+
+ ex.ClientEntity.Should().BeEquivalentTo(existingMovie);
+ ex.ServerEntity.Should().NotBeNull().And.HaveEquivalentMetadataTo(serverEntity).And.BeEquivalentTo(serverEntity);
+ }
+
+ [Fact]
+ public async Task Replace_MissingId_Returns404()
+ {
+ InMemoryMovie existingMovie = GetRandomMovie();
+ ClientMovie source = new(existingMovie) { Id = "missing" };
+ DatasyncServiceClient client = GetMovieClient();
+ DatasyncServiceOptions options = new() { ThrowIfMissing = false };
+
+ ServiceResponse response = await client.ReplaceAsync(source, options);
+
+ response.IsSuccessful.Should().BeFalse();
+ response.StatusCode.Should().Be(404);
+ }
+
+ [Fact]
+ public async Task Replace_MissingId_Returns404_ThrowIfMissing()
+ {
+ InMemoryMovie existingMovie = GetRandomMovie();
+ ClientMovie source = new(existingMovie) { Id = "missing" };
+ DatasyncServiceClient client = GetMovieClient();
+ DatasyncServiceOptions options = new() { ThrowIfMissing = true };
+
+ Func act = async () => _ = await client.ReplaceAsync(source, options);
+ await act.Should().ThrowAsync();
+ }
+ #endregion
+}