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