diff --git a/src/CommunityToolkit.Datasync.Server.Abstractions/Tables/IRepository.cs b/src/CommunityToolkit.Datasync.Server.Abstractions/Tables/IRepository.cs index 268b0db6..9a5fde7d 100644 --- a/src/CommunityToolkit.Datasync.Server.Abstractions/Tables/IRepository.cs +++ b/src/CommunityToolkit.Datasync.Server.Abstractions/Tables/IRepository.cs @@ -66,4 +66,24 @@ public interface IRepository where TEntity : ITableData /// Thrown if the entity creation would produce a normal HTTP error. /// Thrown is there is an error in the repository. ValueTask ReplaceAsync(TEntity entity, byte[]? version = null, CancellationToken cancellationToken = default); + + /// + /// Executes a count against the query provided, which came from this data store. This allows you + /// to override the count operation to provide a more efficient count operation. + /// + /// The queryable being counted. + /// A to observe. + /// The count of entities matching the query. + ValueTask CountAsync(IQueryable query, CancellationToken cancellationToken = default) + => ValueTask.FromResult(query.Cast().Count()); + + /// + /// Executes a query retrieval against the query provided, which came from this data store. This allows you + /// to override the ToList operation to provide a more efficient operation. + /// + /// The queryable being executed. + /// A to observe. + /// The entities matching the query. + ValueTask> ToListAsync(IQueryable query, CancellationToken cancellationToken = default) + => ValueTask.FromResult(query.Cast().ToList()); } diff --git a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/BaseEntityTableData.cs b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/BaseEntityTableData.cs index 159988cd..ea387e7d 100644 --- a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/BaseEntityTableData.cs +++ b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/BaseEntityTableData.cs @@ -10,7 +10,6 @@ namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore; /// /// The base class for all the Entity Framework Core based table data classes. /// -[Index(nameof(UpdatedAt), nameof(Deleted))] public abstract class BaseEntityTableData : ITableData { /// diff --git a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableData.cs b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableData.cs index 4ae46fb1..70c25349 100644 --- a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableData.cs +++ b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableData.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using Microsoft.EntityFrameworkCore; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; @@ -11,6 +12,7 @@ namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore; /// A version of the that is compatible with /// most of the Entity Framework Core drivers. /// +[Index(nameof(UpdatedAt), nameof(Deleted))] public class EntityTableData : BaseEntityTableData { /// diff --git a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs index 64430016..965bc9af 100644 --- a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs +++ b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs @@ -138,7 +138,7 @@ public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken can await WrapExceptionAsync(entity.Id, async () => { // We do not use Any() here because it is not supported by all providers (e.g. Cosmos) - if (DataSet.Count(x => x.Id == entity.Id) > 0) + if ((await DataSet.CountAsync(x => x.Id == entity.Id, cancellationToken)) > 0) { throw new HttpException((int)HttpStatusCode.Conflict) { Payload = await GetEntityAsync(entity.Id, cancellationToken).ConfigureAwait(false) }; } @@ -209,5 +209,23 @@ await WrapExceptionAsync(entity.Id, async () => _ = await Context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); }, cancellationToken).ConfigureAwait(false); } + + /// + /// + /// The entity framework core edition of this method uses the async method. + /// + public virtual async ValueTask CountAsync(IQueryable query, CancellationToken cancellationToken = default) + { + return await EntityFrameworkQueryableExtensions.CountAsync(query.Cast(), cancellationToken).ConfigureAwait(false); + } + + /// + /// + /// The entity framework core edition of this method uses the async method. + /// + public virtual async ValueTask> ToListAsync(IQueryable query, CancellationToken cancellationToken = default) + { + return await EntityFrameworkQueryableExtensions.ToListAsync(query.Cast(), cancellationToken).ConfigureAwait(false); + } #endregion } diff --git a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs index 5e6d75b3..12290648 100644 --- a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs +++ b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs @@ -69,22 +69,32 @@ public virtual async Task QueryAsync(CancellationToken cancellati return BadRequest(validationException.Message); } - // Note that some IQueryable providers cannot execute all queries against the data source, so we have - // to switch to in-memory processing for those queries. This is done by calling ToListAsync() on the - // IQueryable. This is not ideal, but it is the only way to support all of the OData query options. - IEnumerable? results = null; - await ExecuteQueryWithClientEvaluationAsync(dataset, ds => + List? results = null; + await ExecuteQueryWithClientEvaluationAsync(dataset, async ds => { - results = (IEnumerable)queryOptions.ApplyTo(ds, querySettings); - return Task.CompletedTask; + IQueryable query = queryOptions.ApplyTo(ds, querySettings); + results = await Repository.ToListAsync(queryOptions.ApplyTo(ds, querySettings), cancellationToken).ConfigureAwait(false); + + // If the request results in an ISelectExpandWrapper, then $select was used and + // the model will be incomplete. JSON rendering just turns this into a dictionary, + // so we'll do the same here. + if (results.Count > 0) + { + for (int i = 0; i < results.Count; i++) + { + if (results[i] is ISelectExpandWrapper wrapper) + { + results[i] = wrapper.ToDictionary(); + } + } + } }); int count = 0; - FilterQueryOption? filter = queryOptions.Filter; await ExecuteQueryWithClientEvaluationAsync(dataset, async ds => { - IQueryable q = (IQueryable)(filter?.ApplyTo(ds, new ODataQuerySettings()) ?? ds); - count = await CountAsync(q, cancellationToken); + IQueryable q = (IQueryable)(queryOptions.Filter?.ApplyTo(ds, new ODataQuerySettings()) ?? ds); + count = await Repository.CountAsync(q, cancellationToken).ConfigureAwait(false); }); PagedResult result = BuildPagedResult(queryOptions, results, count); @@ -253,18 +263,4 @@ await CatchClientSideEvaluationExceptionAsync(ex, "executing query", async () => [SuppressMessage("Roslynator", "RCS1158:Static member in generic type should use a type parameter.")] internal static bool IsClientSideEvaluationException(Exception? ex) => ex is not null and (InvalidOperationException or NotSupportedException); - - /// - /// This is an overridable method that calls Count() on the provided queryable. You can override - /// this to calls a provider-specific count mechanism (e.g. CountAsync(). - /// - /// - /// - /// - [NonAction] - public virtual Task CountAsync(IQueryable query, CancellationToken cancellationToken) - { - int result = query.Count(); - return Task.FromResult(result); - } } diff --git a/src/CommunityToolkit.Datasync.Server/Models/DatasyncServiceOptions.cs b/src/CommunityToolkit.Datasync.Server/Models/DatasyncServiceOptions.cs index 7cb1a29c..48ababb3 100644 --- a/src/CommunityToolkit.Datasync.Server/Models/DatasyncServiceOptions.cs +++ b/src/CommunityToolkit.Datasync.Server/Models/DatasyncServiceOptions.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Datasync.Server.Abstractions.Json; +using CommunityToolkit.Datasync.Server.OData; using System.Text.Json; using System.Text.Json.Serialization; diff --git a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/AzureSqlEntityTableRepository_Tests.cs b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/AzureSqlEntityTableRepository_Tests.cs index 0511b1b7..bbbd7876 100644 --- a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/AzureSqlEntityTableRepository_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/AzureSqlEntityTableRepository_Tests.cs @@ -35,8 +35,8 @@ public AzureSqlEntityTableRepository_Tests(DatabaseFixture fixture, ITestOutputH protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString); - protected override Task GetEntityAsync(string id) - => Task.FromResult(Context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id)); + protected override async Task GetEntityAsync(string id) + => (await Context.Movies.AsNoTracking().SingleOrDefaultAsync(m => m.Id == id)).Clone(); protected override Task GetEntityCountAsync() => Task.FromResult(Context.Movies.Count()); diff --git a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CosmosEntityTableRepository_Tests.cs b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CosmosEntityTableRepository_Tests.cs index b9d9f59e..755c5ff2 100644 --- a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CosmosEntityTableRepository_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CosmosEntityTableRepository_Tests.cs @@ -7,39 +7,50 @@ using Microsoft.EntityFrameworkCore; using Xunit.Abstractions; +#pragma warning disable CS9113 // Parameter is unread. + namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test; +/// +/// Note that this is a completely different set of tests than the RepositoryTests because CosmosDB +/// EF Core driver is async only - there is no sync version of the driver in EFCore 9.0. They basically +/// broke the driver to enforce async behavior. +/// +/// +/// [ExcludeFromCodeCoverage] [Collection("LiveTestsCollection")] -public class CosmosEntityTableRepository_Tests : RepositoryTests +public class CosmosEntityTableRepository_Tests(DatabaseFixture fixture, ITestOutputHelper output) : RepositoryTests, IAsyncLifetime { #region Setup - private readonly DatabaseFixture _fixture; private readonly Random random = new(); - private readonly string connectionString; - private readonly List movies; - private readonly Lazy _context; + private string connectionString = string.Empty; + private List movies; - public CosmosEntityTableRepository_Tests(DatabaseFixture fixture, ITestOutputHelper output) : base() + public async Task InitializeAsync() { - this._fixture = fixture; this.connectionString = Environment.GetEnvironmentVariable("DATASYNC_COSMOS_CONNECTIONSTRING"); if (!string.IsNullOrEmpty(this.connectionString)) { - this._context = new Lazy(() => CosmosDbContext.CreateContext(this.connectionString, output)); - this.movies = Context.Movies.AsNoTracking().ToList(); + Context = await CosmosDbContext.CreateContextAsync(this.connectionString, output); + this.movies = await Context.Movies.AsNoTracking().ToListAsync(); } } - private CosmosDbContext Context { get => this._context.Value; } + public Task DisposeAsync() + { + return Task.CompletedTask; + } + + private CosmosDbContext Context { get; set; } protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString); - protected override Task GetEntityAsync(string id) - => Task.FromResult(Context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id)); + protected override Task GetEntityAsync(string id) + => Context.Movies.AsNoTracking().SingleOrDefaultAsync(m => m.Id == id); protected override Task GetEntityCountAsync() - => Task.FromResult(Context.Movies.Count()); + => Context.Movies.CountAsync(); protected override Task> GetPopulatedRepositoryAsync() => Task.FromResult>(new EntityTableRepository(Context)); diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Controllers/TableController_Query_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Test/Controllers/TableController_Query_Tests.cs index 5fe84c64..e812b33a 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Controllers/TableController_Query_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Controllers/TableController_Query_Tests.cs @@ -255,30 +255,28 @@ public async Task QueryAsync_RepositoryException_Throws() [Fact] public async Task QueryAsync_NoExtras_Works() { - TableData entity = new() { Id = "0da7fb24-3606-442f-9f68-c47c6e7d09d4" }; + int takeCount = 5; // Should be less than pagesize. - IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true); - IRepository repository = FakeRepository(entity, true); - TableController controller = new(repository, accessProvider); + IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true); + InMemoryRepository repository = new(TestCommon.TestData.Movies.OfType().Take(takeCount).ToList()); + TableController controller = new(repository, accessProvider); controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table"); OkObjectResult result = await controller.QueryAsync() as OkObjectResult; result.Should().NotBeNull(); PagedResult pagedResult = result.Value as PagedResult; pagedResult.Should().NotBeNull(); - pagedResult.Items.Should().HaveCount(1); + pagedResult.Items.Should().HaveCount(takeCount); } [Theory] - [InlineData("0da7fb24-3606-442f-9f68-c47c6e7d09d4", 1)] + [InlineData("id-010", 1)] [InlineData("1", 0)] public async Task QueryAsync_DataView_Works(string filter, int count) { - TableData entity = new() { Id = "0da7fb24-3606-442f-9f68-c47c6e7d09d4" }; - - IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true, m => m.Id == filter); - IRepository repository = FakeRepository(entity, true); - TableController controller = new(repository, accessProvider); + IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true, m => m.Id == filter); + InMemoryRepository repository = new(TestCommon.TestData.Movies.OfType()); + TableController controller = new(repository, accessProvider); controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table"); OkObjectResult result = await controller.QueryAsync() as OkObjectResult; @@ -289,43 +287,61 @@ public async Task QueryAsync_DataView_Works(string filter, int count) } [Theory] - [InlineData(true, 0)] - [InlineData(false, 1)] - public async Task QueryAsync_DeletedSkipped_Works(bool isDeleted, int count) + [InlineData(true)] + [InlineData(false)] + public async Task QueryAsync_DeletedSkipped_Works(bool isDeleted) { - TableData entity = new() { Id = "0da7fb24-3606-442f-9f68-c47c6e7d09d4", Deleted = isDeleted }; + IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true); + InMemoryRepository repository = new(TestCommon.TestData.Movies.OfType()); + + // Set the deleted flag on the first item in the repository, but only if isDeleted == true + int expectedCount = TestCommon.TestData.Movies.MovieList.Length; + if (isDeleted) + { + InMemoryMovie entity = repository.GetEntity("id-010"); + entity.Deleted = isDeleted; + repository.StoreEntity(entity); + expectedCount--; + } - IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true); - IRepository repository = FakeRepository(entity, true); TableControllerOptions options = new() { EnableSoftDelete = true }; - TableController controller = new(repository, accessProvider) { Options = options }; - controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table"); + TableController controller = new(repository, accessProvider) { Options = options }; + controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table?$count=true"); OkObjectResult result = await controller.QueryAsync() as OkObjectResult; result.Should().NotBeNull(); PagedResult pagedResult = result.Value as PagedResult; pagedResult.Should().NotBeNull(); - pagedResult.Items.Should().HaveCount(count); + pagedResult.Items.Should().HaveCount(100); + pagedResult.Count.Should().Be(expectedCount); // Total count } [Theory] - [InlineData(true, 1)] - [InlineData(false, 1)] - public async Task QueryAsync_DeletedIncluded_Works(bool isDeleted, int count) + [InlineData(true)] + [InlineData(false)] + public async Task QueryAsync_DeletedIncluded_Works(bool isDeleted) { - TableData entity = new() { Id = "0da7fb24-3606-442f-9f68-c47c6e7d09d4", Deleted = isDeleted }; + IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true); + InMemoryRepository repository = new(TestCommon.TestData.Movies.OfType()); + + // Set the deleted flag on the first item in the repository, but only if isDeleted == true + if (isDeleted) + { + InMemoryMovie entity = repository.GetEntity("id-010"); + entity.Deleted = isDeleted; + repository.StoreEntity(entity); + } - IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true); - IRepository repository = FakeRepository(entity, true); TableControllerOptions options = new() { EnableSoftDelete = true }; - TableController controller = new(repository, accessProvider) { Options = options }; - controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table?__includedeleted=true"); + TableController controller = new(repository, accessProvider) { Options = options }; + controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table?$count=true&__includedeleted=true"); OkObjectResult result = await controller.QueryAsync() as OkObjectResult; result.Should().NotBeNull(); PagedResult pagedResult = result.Value as PagedResult; pagedResult.Should().NotBeNull(); - pagedResult.Items.Should().HaveCount(count); + pagedResult.Items.Should().HaveCount(100); // Page length + pagedResult.Count.Should().Be(TestCommon.TestData.Movies.MovieList.Length); // Total count } #endregion } diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs index 4f6eb850..9d6cd362 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs @@ -2,7 +2,9 @@ // 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; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Xunit.Abstractions; @@ -37,7 +39,7 @@ internal void InitializeDatabase() UPDATE [dbo].[{0}] SET - [UpdatedAt] = GETUTCDATE() + [UpdatedAt] = SYSDATETIMEOFFSET() WHERE [Id] IN (SELECT [Id] FROM INSERTED); END @@ -56,6 +58,10 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity(entity => + { + entity.Property(e => e.UpdatedAt).HasColumnType("datetimeoffset(7)").IsRequired(false); + }); modelBuilder.Entity().ToTable(tb => tb.HasTrigger("datasync_trigger")); base.OnModelCreating(modelBuilder); } diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/Base/BaseDbContext.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/Base/BaseDbContext.cs index 0b40c2e3..a8c0b1b1 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/Databases/Base/BaseDbContext.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/Databases/Base/BaseDbContext.cs @@ -49,6 +49,18 @@ protected void ExecuteRawSqlOnEachEntity(string format) /// Populates the database with the core set of movies. Ensures that we have the same data for all tests. /// protected void PopulateDatabase() + { + AddMoviesToTable(); + SaveChanges(); + } + + protected async Task PopulateDatabaseAsync() + { + AddMoviesToTable(); + await SaveChangesAsync(); + } + + private void AddMoviesToTable() { List movies = [.. TestData.Movies.OfType()]; MovieIds = movies.ConvertAll(m => m.Id); @@ -70,7 +82,5 @@ protected void PopulateDatabase() Movies.Add(movie); } - - SaveChanges(); } } \ No newline at end of file diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/CosmosDb/CosmosDbContext.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/CosmosDb/CosmosDbContext.cs index d69b8c18..fe154ba1 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/Databases/CosmosDb/CosmosDbContext.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/Databases/CosmosDb/CosmosDbContext.cs @@ -11,7 +11,7 @@ namespace CommunityToolkit.Datasync.TestCommon.Databases; [ExcludeFromCodeCoverage] public class CosmosDbContext(DbContextOptions options) : BaseDbContext(options) { - public static CosmosDbContext CreateContext(string connectionString, ITestOutputHelper output = null) + public static async Task CreateContextAsync(string connectionString, ITestOutputHelper output = null) { if (string.IsNullOrEmpty(connectionString)) { @@ -23,15 +23,16 @@ public static CosmosDbContext CreateContext(string connectionString, ITestOutput .EnableLogging(output); CosmosDbContext context = new(optionsBuilder.Options); - context.InitializeDatabase(); - context.PopulateDatabase(); + await context.InitializeDatabaseAsync(); + await context.PopulateDatabaseAsync(); return context; } - internal void InitializeDatabase() + internal async Task InitializeDatabaseAsync() { - RemoveRange(Movies.ToList()); - SaveChanges(); + List toDelete = await Movies.ToListAsync(); + RemoveRange(toDelete); + await SaveChangesAsync(); } protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/HttpExceptionAssertions.cs b/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/HttpExceptionAssertions.cs index 7bcea058..935dafd4 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/HttpExceptionAssertions.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/HttpExceptionAssertions.cs @@ -14,9 +14,16 @@ public static class FluentHttpExceptionAssertions /// /// An extension to FluentAssertions to validate the payload of a . /// - public static AndConstraint> WithPayload(this ExceptionAssertions current, object payload, string because = "", params object[] becauseArgs) + public static AndConstraint> WithPayload(this ExceptionAssertions current, object expectedPayload, string because = "", params object[] becauseArgs) { - current.Subject.First().Payload.Should().NotBeNull().And.BeEquivalentTo(payload, because, becauseArgs); + object actualPayload = current.Subject.First().Payload; + actualPayload.Should().NotBeNull(); + + // The UpdatedAt field should be within 1msec of the subject. + actualPayload.Should().BeEquivalentTo(expectedPayload, options => options + .Using(ctx => ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromMilliseconds(1))) + .WhenTypeIs(), because, becauseArgs); + return new AndConstraint>(current); } diff --git a/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs b/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs index 68839fc0..9412453c 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs @@ -65,19 +65,6 @@ public async Task AsQueryableAsync_ReturnsQueryable() sut.Should().NotBeNull().And.BeAssignableTo>(); } - [SkippableFact] - public async Task AsQueryableAsync_CanRetrieveSingleItems() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(true); - TEntity expected = await GetEntityAsync(id); - TEntity actual = (await Repository.AsQueryableAsync()).Single(m => m.Id == id); - - actual.Should().BeEquivalentTo(expected); - } - [SkippableFact] public async Task AsQueryableAsync_CanRetrieveFilteredLists() { @@ -86,7 +73,7 @@ public async Task AsQueryableAsync_CanRetrieveFilteredLists() IRepository Repository = await GetPopulatedRepositoryAsync(); int expected = TestData.Movies.Count(m => m.Rating == MovieRating.R); IQueryable queryable = await Repository.AsQueryableAsync(); - List actual = queryable.Where(m => m.Rating == MovieRating.R).ToList(); + List actual = await Repository.ToListAsync(queryable.Where(m => m.Rating == MovieRating.R)); actual.Should().HaveCount(expected); } @@ -99,7 +86,7 @@ public async Task AsQueryableAsync_CanSelectFromList() IRepository Repository = await GetPopulatedRepositoryAsync(); int expected = TestData.Movies.Count(m => m.Rating == MovieRating.R); IQueryable queryable = await Repository.AsQueryableAsync(); - var actual = queryable.Where(m => m.Rating == MovieRating.R).Select(m => new { m.Id, m.Title }).ToList(); + List actual = await Repository.ToListAsync(queryable.Where(m => m.Rating == MovieRating.R).Select(m => new { m.Id, m.Title })); actual.Should().HaveCount(expected); } @@ -111,7 +98,7 @@ public async Task AsQueryableAsync_CanUseTopAndSkip() IRepository Repository = await GetPopulatedRepositoryAsync(); IQueryable queryable = await Repository.AsQueryableAsync(); - List actual = queryable.Where(m => m.Rating == MovieRating.R).Skip(5).Take(20).ToList(); + List actual = await Repository.ToListAsync(queryable.Where(m => m.Rating == MovieRating.R).Skip(5).Take(20)); actual.Should().HaveCount(20); } @@ -126,7 +113,7 @@ public async Task AsQueryableAsync_CanRetrievePagedDatasyncQuery() IRepository Repository = await GetPopulatedRepositoryAsync(); IQueryable queryable = await Repository.AsQueryableAsync(); - List actual = queryable.Where(m => m.UpdatedAt > DateTimeOffset.UnixEpoch && !m.Deleted).OrderBy(m => m.UpdatedAt).Skip(10).Take(10).ToList(); + List actual = await Repository.ToListAsync(queryable.Where(m => m.UpdatedAt > DateTimeOffset.UnixEpoch && !m.Deleted).OrderBy(m => m.UpdatedAt).Skip(10).Take(10)); actual.Should().HaveCount(10); } @@ -138,17 +125,19 @@ public async Task CreateAsync_CreatesNewEntity_WithSpecifiedId() { Skip.IfNot(CanRunLiveTests()); + DateTimeOffset dto = DateTimeOffset.Now; IRepository Repository = await GetPopulatedRepositoryAsync(); string id = await GetRandomEntityIdAsync(false); TEntity addition = TestData.Movies.OfType(TestData.Movies.BlackPanther, id); TEntity sut = addition.Clone(); await Repository.CreateAsync(sut); + TEntity actual = await GetEntityAsync(id); actual.Should().BeEquivalentTo(addition); actual.Should().NotBeEquivalentTo(addition).And.HaveEquivalentMetadataTo(sut); actual.Id.Should().Be(id); - actual.UpdatedAt.Should().BeAfter(StartTime); + actual.UpdatedAt?.Ticks.Should().BeGreaterThan(dto.Ticks); actual.Version.Should().NotBeNullOrEmpty(); } @@ -159,6 +148,7 @@ public async Task CreateAsync_CreatesNewEntity_WithNullId(string id) { Skip.IfNot(CanRunLiveTests()); + DateTimeOffset dto = DateTimeOffset.Now; IRepository Repository = await GetPopulatedRepositoryAsync(); TEntity addition = TestData.Movies.OfType(TestData.Movies.BlackPanther); addition.Id = id; @@ -167,7 +157,7 @@ public async Task CreateAsync_CreatesNewEntity_WithNullId(string id) TEntity actual = await GetEntityAsync(sut.Id); actual.Should().BeEquivalentTo(addition); - actual.UpdatedAt.Should().BeAfter(StartTime); + actual.UpdatedAt?.Ticks.Should().BeGreaterThan(dto.Ticks); } [SkippableFact] @@ -190,6 +180,7 @@ public async Task CreateAsync_UpdatesMetadata() { Skip.IfNot(CanRunLiveTests()); + DateTimeOffset dto = DateTimeOffset.Now; IRepository Repository = await GetPopulatedRepositoryAsync(); string id = await GetRandomEntityIdAsync(false); TEntity addition = TestData.Movies.OfType(TestData.Movies.BlackPanther, id); @@ -203,7 +194,7 @@ public async Task CreateAsync_UpdatesMetadata() actual.Should().BeEquivalentTo(addition); actual.Should().NotBeEquivalentTo(addition).And.HaveEquivalentMetadataTo(sut); actual.Id.Should().Be(id); - actual.UpdatedAt.Should().BeAfter(StartTime); + actual.UpdatedAt?.Ticks.Should().BeGreaterThan(dto.Ticks); actual.Version.Should().NotBeEquivalentTo(expectedVersion); } @@ -308,7 +299,9 @@ public async Task ReadAsync_ReturnsDisconnectedEntity() TEntity expected = await GetEntityAsync(id); TEntity actual = await Repository.ReadAsync(id); - actual.Should().BeEquivalentTo(expected).And.NotBeSameAs(expected); + actual.Should().BeEquivalentTo(expected); + actual.Should().HaveEquivalentMetadataTo(expected); + actual.Should().NotBeSameAs(expected); } [SkippableTheory] @@ -386,6 +379,7 @@ public async Task ReplaceAsync_Replaces_OnVersionMatch() { Skip.IfNot(CanRunLiveTests()); + DateTimeOffset dto = DateTimeOffset.Now; IRepository Repository = await GetPopulatedRepositoryAsync(); string id = await GetRandomEntityIdAsync(true); TEntity replacement = TestData.Movies.OfType(TestData.Movies.BlackPanther, id); @@ -396,7 +390,7 @@ public async Task ReplaceAsync_Replaces_OnVersionMatch() actual.Should().BeEquivalentTo(replacement).And.NotBeEquivalentTo(expected); actual.Version.Should().NotBeEquivalentTo(version); - actual.UpdatedAt.Should().BeAfter(StartTime); + actual.UpdatedAt?.Ticks.Should().BeGreaterThan(dto.Ticks); } [SkippableFact] @@ -404,6 +398,7 @@ public async Task ReplaceAsync_Replaces_OnNoVersion() { Skip.IfNot(CanRunLiveTests()); + DateTimeOffset dto = DateTimeOffset.Now; IRepository Repository = await GetPopulatedRepositoryAsync(); string id = await GetRandomEntityIdAsync(true); TEntity replacement = TestData.Movies.OfType(TestData.Movies.BlackPanther, id); @@ -414,7 +409,8 @@ public async Task ReplaceAsync_Replaces_OnNoVersion() actual.Should().BeEquivalentTo(replacement).And.NotBeEquivalentTo(expected); actual.Version.Should().NotBeEquivalentTo(version); - actual.UpdatedAt.Should().BeAfter(StartTime); + actual.UpdatedAt?.Ticks.Should().BeGreaterThan(dto.Ticks); + } #endregion }