Skip to content

Commit 4eeadad

Browse files
authored
(#80) Integration tests (#89)
* (#80) Online client tests. * (#80) Integration tests
1 parent 57cdfc7 commit 4eeadad

File tree

13 files changed

+1548
-17
lines changed

13 files changed

+1548
-17
lines changed

src/CommunityToolkit.Datasync.Client/Offline/DeltaTokenStore/DefaultDeltaTokenStore.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,21 +48,24 @@ public async Task ResetDeltaTokenAsync(string queryId, CancellationToken cancell
4848
/// <param name="queryId">The query ID of the table.</param>
4949
/// <param name="value">The value of the delta token.</param>
5050
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
51-
/// <returns>A task that completes when the delta token has been set in the persistent store.</returns>
52-
public async Task SetDeltaTokenAsync(string queryId, DateTimeOffset value, CancellationToken cancellationToken = default)
51+
/// <returns>true if this query ID was set for the first time; false otherwise.</returns>
52+
public async Task<bool> SetDeltaTokenAsync(string queryId, DateTimeOffset value, CancellationToken cancellationToken = default)
5353
{
5454
ValidateQueryId(queryId);
5555
long unixms = value.ToUnixTimeMilliseconds();
5656
DatasyncDeltaToken? deltaToken = await context.DatasyncDeltaTokens.FindAsync([queryId], cancellationToken).ConfigureAwait(false);
5757
if (deltaToken is null)
5858
{
5959
_ = context.DatasyncDeltaTokens.Add(new DatasyncDeltaToken() { Id = queryId, Value = unixms });
60+
return true;
6061
}
6162
else if (deltaToken.Value != unixms)
6263
{
6364
deltaToken.Value = unixms;
6465
_ = context.DatasyncDeltaTokens.Update(deltaToken);
6566
}
67+
68+
return false;
6669
}
6770

6871
/// <summary>

src/CommunityToolkit.Datasync.Client/Offline/DeltaTokenStore/IDeltaTokenStore.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,5 @@ internal interface IDeltaTokenStore
3232
/// <param name="value">The value of the delta token.</param>
3333
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
3434
/// <returns>A task that completes when the delta token has been set in the persistent store.</returns>
35-
Task SetDeltaTokenAsync(string queryId, DateTimeOffset value, CancellationToken cancellationToken = default);
35+
Task<bool> SetDeltaTokenAsync(string queryId, DateTimeOffset value, CancellationToken cancellationToken = default);
3636
}

src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,12 @@ public async Task<PullResult> ExecuteAsync(IEnumerable<PullRequest> requests, Pu
7474
if (metadata.UpdatedAt.HasValue && metadata.UpdatedAt.Value > lastSynchronization)
7575
{
7676
lastSynchronization = metadata.UpdatedAt.Value;
77-
await DeltaTokenStore.SetDeltaTokenAsync(pullResponse.QueryId, metadata.UpdatedAt.Value, cancellationToken).ConfigureAwait(false);
77+
bool isAdded = await DeltaTokenStore.SetDeltaTokenAsync(pullResponse.QueryId, metadata.UpdatedAt.Value, cancellationToken).ConfigureAwait(false);
78+
if (isAdded)
79+
{
80+
// Sqlite oddity - you can't add then update; it changes the change type to UPDATE, which then fails.
81+
_ = await context.SaveChangesAsync(true, false, cancellationToken).ConfigureAwait(false);
82+
}
7883
}
7984
}
8085

@@ -117,7 +122,7 @@ public async Task<PullResult> ExecuteAsync(IEnumerable<PullRequest> requests, Pu
117122
foreach (PullRequest request in requests)
118123
{
119124
DateTimeOffset lastSynchronization = await context.DeltaTokenStore.GetDeltaTokenAsync(request.QueryId, cancellationToken).ConfigureAwait(false);
120-
PrepareQueryDescription(request.QueryDescription, lastSynchronization);
125+
request.QueryDescription = PrepareQueryDescription(request.QueryDescription, lastSynchronization);
121126
serviceRequestQueue.Enqueue(request);
122127
}
123128

@@ -171,10 +176,12 @@ internal async Task<Page<object>> GetPageAsync(HttpClient client, Uri requestUri
171176
/// <summary>
172177
/// Prepares the query description for use as a pull request.
173178
/// </summary>
174-
/// <param name="query">The query description to modify.</param>
179+
/// <param name="source">The query description to modify.</param>
175180
/// <param name="lastSynchronization">The last synchronization date/time</param>
176-
internal static void PrepareQueryDescription(QueryDescription query, DateTimeOffset lastSynchronization)
181+
/// <returns>A modified query description for the actual query.</returns>
182+
internal static QueryDescription PrepareQueryDescription(QueryDescription source, DateTimeOffset lastSynchronization)
177183
{
184+
QueryDescription query = new(source);
178185
if (lastSynchronization.ToUnixTimeMilliseconds() > 0L)
179186
{
180187
BinaryOperatorNode deltaTokenFilter = new(BinaryOperatorKind.GreaterThan)
@@ -185,12 +192,13 @@ internal static void PrepareQueryDescription(QueryDescription query, DateTimeOff
185192
query.Filter = query.Filter is null ? deltaTokenFilter : new BinaryOperatorNode(BinaryOperatorKind.And, query.Filter, deltaTokenFilter);
186193
}
187194

188-
query.QueryParameters.Add(ODataQueryParameters.IncludeDeleted, "true");
195+
query.QueryParameters[ODataQueryParameters.IncludeDeleted] = "true";
189196
query.RequestTotalCount = true;
190197
query.Top = null;
191198
query.Skip = 0;
192199
query.Ordering.Clear();
193200
query.Ordering.Add(new OrderByNode(new MemberAccessNode(null, "updatedAt"), true));
201+
return query;
194202
}
195203

196204
/// <summary>

src/CommunityToolkit.Datasync.Client/Query/Linq/QueryDescription.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,30 @@ namespace CommunityToolkit.Datasync.Client.Query.Linq;
1414
/// </summary>
1515
internal class QueryDescription
1616
{
17+
/// <summary>
18+
/// Creates a new blank <see cref="QueryDescription"/>
19+
/// </summary>
20+
internal QueryDescription()
21+
{
22+
}
23+
24+
/// <summary>
25+
/// Creates a new <see cref="QueryDescription"/> based on a source <see cref="QueryDescription"/>.
26+
/// </summary>
27+
/// <param name="source">The source of the <see cref="QueryDescription"/></param>
28+
internal QueryDescription(QueryDescription source)
29+
{
30+
Filter = source.Filter; // Note: we don't clone the filter, so you have to be careful to not change any nodes.
31+
RequestTotalCount = source.RequestTotalCount;
32+
Ordering = [..source.Ordering];
33+
ProjectionArgumentType = source.ProjectionArgumentType;
34+
Projections = [..source.Projections];
35+
QueryParameters = new Dictionary<string, string>(source.QueryParameters);
36+
Selection = new List<string>(source.Selection);
37+
Skip = source.Skip;
38+
Top = source.Top;
39+
}
40+
1741
/// <summary>
1842
/// The <see cref="QueryNode"/> for the query filter expression.
1943
/// </summary>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.Client.Offline;
6+
using CommunityToolkit.Datasync.TestCommon.Databases;
7+
using Microsoft.Data.Sqlite;
8+
using Microsoft.EntityFrameworkCore;
9+
10+
namespace CommunityToolkit.Datasync.Client.Test.Helpers;
11+
12+
[ExcludeFromCodeCoverage]
13+
public class IntegrationDbContext(DbContextOptions<IntegrationDbContext> options) : OfflineDbContext(options)
14+
{
15+
public DbSet<ClientMovie> Movies => Set<ClientMovie>();
16+
17+
public ServiceApplicationFactory Factory { get; set; }
18+
19+
public SqliteConnection Connection { get; set; }
20+
21+
public string Filename { get; set; }
22+
23+
protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder)
24+
{
25+
optionsBuilder.UseHttpClient(Factory.CreateClient());
26+
optionsBuilder.Entity<ClientMovie>(cfg =>
27+
{
28+
cfg.ClientName = "movies";
29+
cfg.Endpoint = new Uri($"/{Factory.MovieEndpoint}", UriKind.Relative);
30+
});
31+
}
32+
33+
protected override void Dispose(bool disposing)
34+
{
35+
if (disposing)
36+
{
37+
Connection.Close();
38+
}
39+
40+
base.Dispose(disposing);
41+
}
42+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.Server.InMemory;
6+
using CommunityToolkit.Datasync.Server;
7+
using Microsoft.AspNetCore.Hosting;
8+
using Microsoft.AspNetCore.Mvc.Testing;
9+
using Microsoft.Extensions.DependencyInjection;
10+
using CommunityToolkit.Datasync.TestCommon.Databases;
11+
using CommunityToolkit.Datasync.TestCommon.Models;
12+
using System.Linq.Expressions;
13+
14+
namespace CommunityToolkit.Datasync.Client.Test.Helpers;
15+
16+
[ExcludeFromCodeCoverage]
17+
public class ServiceApplicationFactory : WebApplicationFactory<Program>
18+
{
19+
internal string KitchenSinkEndpoint = "api/in-memory/kitchensink";
20+
internal string MovieEndpoint = "api/in-memory/movies";
21+
internal string PagedMovieEndpoint = "api/in-memory/pagedmovies";
22+
internal string SoftDeletedMovieEndpoint = "api/in-memory/softmovies";
23+
24+
protected override void ConfigureWebHost(IWebHostBuilder builder)
25+
{
26+
builder.UseEnvironment("Development");
27+
// base.ConfigureWebHost(builder);
28+
}
29+
30+
internal IList<TEntity> GetEntities<TEntity>() where TEntity : InMemoryTableData
31+
{
32+
using IServiceScope scope = Services.CreateScope();
33+
InMemoryRepository<TEntity> repository = scope.ServiceProvider.GetRequiredService<IRepository<TEntity>>() as InMemoryRepository<TEntity>;
34+
return [.. repository.GetEntities()];
35+
}
36+
37+
internal int Count<TEntity>() where TEntity : InMemoryTableData
38+
{
39+
using IServiceScope scope = Services.CreateScope();
40+
InMemoryRepository<TEntity> repository = scope.ServiceProvider.GetRequiredService<IRepository<TEntity>>() as InMemoryRepository<TEntity>;
41+
return repository.GetEntities().Count;
42+
}
43+
44+
internal InMemoryMovie GetRandomMovie()
45+
{
46+
// Note that we don't use all movies, since some of them are not "valid", which will result in a 400 error instead
47+
// of the expected error when testing replace or create functionality.
48+
using IServiceScope scope = Services.CreateScope();
49+
InMemoryRepository<InMemoryMovie> repository = scope.ServiceProvider.GetRequiredService<IRepository<InMemoryMovie>>() as InMemoryRepository<InMemoryMovie>;
50+
List<InMemoryMovie> entities = repository.GetEntities().Where(x => IsValid(x)).ToList();
51+
return entities[new Random().Next(entities.Count)];
52+
}
53+
54+
internal TEntity GetServerEntityById<TEntity>(string id) where TEntity : InMemoryTableData
55+
{
56+
using IServiceScope scope = Services.CreateScope();
57+
InMemoryRepository<TEntity> repository = scope.ServiceProvider.GetRequiredService<IRepository<TEntity>>() as InMemoryRepository<TEntity>;
58+
return repository.GetEntity(id);
59+
}
60+
61+
/// <summary>
62+
/// Checks that the movie is "valid" according to the server.
63+
/// </summary>
64+
/// <param name="movie">The movie to check.</param>
65+
/// <returns><c>true</c> if valid, <c>false</c> otherwise.</returns>
66+
internal static bool IsValid(IMovie movie)
67+
{
68+
return movie.Title.Length >= 2 && movie.Title.Length <= 60
69+
&& movie.Year >= 1920 && movie.Year <= 2030
70+
&& movie.Duration >= 60 && movie.Duration <= 360;
71+
}
72+
73+
internal void RunWithRepository<TEntity>(Action<InMemoryRepository<TEntity>> action) where TEntity : InMemoryTableData
74+
{
75+
using IServiceScope scope = Services.CreateScope();
76+
InMemoryRepository<TEntity> repository = scope.ServiceProvider.GetRequiredService<IRepository<TEntity>>() as InMemoryRepository<TEntity>;
77+
action.Invoke(repository);
78+
}
79+
80+
internal void SoftDelete<TEntity>(TEntity entity, bool deleted = true) where TEntity : InMemoryTableData
81+
{
82+
using IServiceScope scope = Services.CreateScope();
83+
InMemoryRepository<TEntity> repository = scope.ServiceProvider.GetRequiredService<IRepository<TEntity>>() as InMemoryRepository<TEntity>;
84+
entity.Deleted = deleted;
85+
repository.StoreEntity(entity);
86+
}
87+
88+
internal void SoftDelete<TEntity>(Expression<Func<TEntity, bool>> expression, bool deleted = true) where TEntity : InMemoryTableData
89+
{
90+
using IServiceScope scope = Services.CreateScope();
91+
InMemoryRepository<TEntity> repository = scope.ServiceProvider.GetRequiredService<IRepository<TEntity>>() as InMemoryRepository<TEntity>;
92+
foreach (TEntity entity in repository.GetEntities().Where(expression.Compile()))
93+
{
94+
entity.Deleted = deleted;
95+
repository.StoreEntity(entity);
96+
}
97+
}
98+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.Server.InMemory;
6+
using CommunityToolkit.Datasync.TestCommon.Databases;
7+
using CommunityToolkit.Datasync.TestCommon.Models;
8+
using CommunityToolkit.Datasync.TestCommon.TestData;
9+
using Microsoft.Data.Sqlite;
10+
using Microsoft.EntityFrameworkCore;
11+
using Microsoft.Spatial;
12+
using System.Linq.Expressions;
13+
14+
namespace CommunityToolkit.Datasync.Client.Test.Helpers;
15+
16+
[ExcludeFromCodeCoverage]
17+
public abstract class ServiceTest(ServiceApplicationFactory factory)
18+
{
19+
protected readonly HttpClient client = factory.CreateClient();
20+
21+
protected DateTimeOffset StartTime { get; } = DateTimeOffset.UtcNow;
22+
23+
internal IntegrationDbContext GetOfflineContext(bool useRealFile = false)
24+
{
25+
string filename = null;
26+
string connectionString = "Data Source=:memory:";
27+
if (useRealFile)
28+
{
29+
filename = Path.GetTempFileName();
30+
SqliteConnectionStringBuilder builder = new();
31+
builder.DataSource = filename;
32+
builder.Mode = SqliteOpenMode.ReadWriteCreate;
33+
connectionString = builder.ConnectionString;
34+
}
35+
36+
SqliteConnection connection = new(connectionString);
37+
connection.Open();
38+
39+
DbContextOptionsBuilder<IntegrationDbContext> optionsBuilder = new();
40+
optionsBuilder.UseSqlite(connection);
41+
optionsBuilder.LogTo(Console.WriteLine);
42+
optionsBuilder.EnableSensitiveDataLogging();
43+
optionsBuilder.EnableDetailedErrors();
44+
45+
IntegrationDbContext context = new(optionsBuilder.Options)
46+
{
47+
Factory = factory,
48+
Filename = filename,
49+
Connection = connection
50+
};
51+
52+
context.Database.EnsureCreated();
53+
return context;
54+
}
55+
56+
internal DatasyncServiceClient<ClientMovie> GetMovieClient()
57+
=> new(new Uri($"/{factory.MovieEndpoint}", UriKind.Relative), this.client);
58+
59+
internal DatasyncServiceClient<ClientMovie> GetSoftDeletedMovieClient()
60+
=> new(new Uri($"/{factory.SoftDeletedMovieEndpoint}", UriKind.Relative), this.client);
61+
62+
internal DatasyncServiceClient<ClientKitchenSink> GetKitchenSinkClient()
63+
=> new(new Uri($"/{factory.KitchenSinkEndpoint}", UriKind.Relative), this.client);
64+
65+
internal int Count<TEntity>() where TEntity : InMemoryTableData
66+
=> factory.Count<TEntity>();
67+
68+
internal InMemoryMovie GetRandomMovie()
69+
=> factory.GetRandomMovie();
70+
71+
internal TEntity GetServerEntityById<TEntity>(string id) where TEntity : InMemoryTableData
72+
=> factory.GetServerEntityById<TEntity>(id);
73+
74+
protected void SeedKitchenSinkWithCountryData()
75+
{
76+
factory.RunWithRepository<InMemoryKitchenSink>(repository =>
77+
{
78+
repository.Clear();
79+
foreach (Country countryRecord in CountryData.GetCountries())
80+
{
81+
InMemoryKitchenSink model = new()
82+
{
83+
Id = countryRecord.IsoCode,
84+
Version = Guid.NewGuid().ToByteArray(),
85+
UpdatedAt = DateTimeOffset.UtcNow,
86+
Deleted = false,
87+
PointValue = GeographyPoint.Create(countryRecord.Latitude, countryRecord.Longitude),
88+
StringValue = countryRecord.CountryName
89+
};
90+
repository.StoreEntity(model);
91+
}
92+
});
93+
}
94+
95+
protected void SeedKitchenSinkWithDateTimeData()
96+
{
97+
factory.RunWithRepository<InMemoryKitchenSink>(repository =>
98+
{
99+
repository.Clear();
100+
DateOnly SourceDate = new(2022, 1, 1);
101+
for (int i = 0; i < 365; i++)
102+
{
103+
DateOnly date = SourceDate.AddDays(i);
104+
InMemoryKitchenSink model = new()
105+
{
106+
Id = string.Format("id-{0:000}", i),
107+
Version = Guid.NewGuid().ToByteArray(),
108+
UpdatedAt = DateTimeOffset.UtcNow,
109+
Deleted = false,
110+
DateOnlyValue = date,
111+
TimeOnlyValue = new TimeOnly(date.Month, date.Day)
112+
};
113+
repository.StoreEntity(model);
114+
}
115+
});
116+
}
117+
118+
internal void SoftDelete<TEntity>(TEntity entity, bool deleted = true) where TEntity : InMemoryTableData
119+
=> factory.SoftDelete<TEntity>(entity, deleted);
120+
121+
internal void SoftDelete<TEntity>(Expression<Func<TEntity, bool>> expression, bool deleted = true) where TEntity : InMemoryTableData
122+
=> factory.SoftDelete<TEntity>(expression, deleted);
123+
}

0 commit comments

Comments
 (0)