diff --git a/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj b/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj index 8cd7619..a0e3ed6 100644 --- a/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj +++ b/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj @@ -1,4 +1,4 @@ - + The client capabilities for developing applications using the Datasync Toolkit. @@ -13,5 +13,6 @@ + diff --git a/src/CommunityToolkit.Datasync.Client/Exceptions/ArgumentValidationException.cs b/src/CommunityToolkit.Datasync.Client/Exceptions/ArgumentValidationException.cs new file mode 100644 index 0000000..981aa84 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Exceptions/ArgumentValidationException.cs @@ -0,0 +1,63 @@ +// 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 System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; + +namespace CommunityToolkit.Datasync.Client; + +/// +/// An that throws on a data validation exception. +/// +public class ArgumentValidationException : ArgumentException +{ + /// + [ExcludeFromCodeCoverage] + public ArgumentValidationException() : base() { } + + /// + [ExcludeFromCodeCoverage] + public ArgumentValidationException(string? message) : base(message) { } + + /// + [ExcludeFromCodeCoverage] + public ArgumentValidationException(string? message, Exception? innerException) : base(message, innerException) { } + + /// + [ExcludeFromCodeCoverage] + public ArgumentValidationException(string? message, string? paramName) : base(message, paramName) { } + + /// + [ExcludeFromCodeCoverage] + public ArgumentValidationException(string? message, string? paramName, Exception? innerException) : base(message, paramName, innerException) { } + + /// + /// The list of validation errors. + /// + public IList? ValidationErrors { get; private set; } + + /// + /// Throws an exception if the object is not valid according to data annotations. + /// + /// The value being tested. + /// The name of the parameter. + public static void ThrowIfNotValid(object? value, string? paramName) + => ThrowIfNotValid(value, paramName, "Object is not valid"); + + /// + /// Throws an exception if the object is not valid according to data annotations. + /// + /// The value being tested. + /// The name of the parameter. + /// The message for the object. + public static void ThrowIfNotValid(object? value, string? paramName, string? message) + { + ArgumentNullException.ThrowIfNull(value, paramName); + List results = []; + if (!Validator.TryValidateObject(value, new ValidationContext(value), results, validateAllProperties: true)) + { + throw new ArgumentValidationException(message, paramName) { ValidationErrors = results }; + } + } +} diff --git a/src/CommunityToolkit.Datasync.Client/Exceptions/DatasyncHttpException.cs b/src/CommunityToolkit.Datasync.Client/Exceptions/DatasyncHttpException.cs index 1977b7f..af1d103 100644 --- a/src/CommunityToolkit.Datasync.Client/Exceptions/DatasyncHttpException.cs +++ b/src/CommunityToolkit.Datasync.Client/Exceptions/DatasyncHttpException.cs @@ -2,8 +2,6 @@ // 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.Service; - namespace CommunityToolkit.Datasync.Client; /// diff --git a/src/CommunityToolkit.Datasync.Client/Http/BasicHttpClientFactory.cs b/src/CommunityToolkit.Datasync.Client/Http/BasicHttpClientFactory.cs new file mode 100644 index 0000000..5870f2a --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Http/BasicHttpClientFactory.cs @@ -0,0 +1,16 @@ +// 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. + +namespace CommunityToolkit.Datasync.Client.Http; + +/// +/// A implementation that always +/// returns the same . +/// +/// The to use +internal class BasicHttpClientFactory(HttpClient client) : IHttpClientFactory +{ + /// + public HttpClient CreateClient(string name) => client; +} diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptions.cs b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptions.cs new file mode 100644 index 0000000..b0c65fa --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptions.cs @@ -0,0 +1,21 @@ +// 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. + +namespace CommunityToolkit.Datasync.Client.Offline; + +/// +/// The options to use for offline operations. +/// +internal class DatasyncOfflineOptions(HttpClient client, Uri endpoint) +{ + /// + /// The to use for this request. + /// + public HttpClient HttpClient { get; } = client; + + /// + /// The relative or absolute URI to the endpoint for this request. + /// + public Uri Endpoint { get; } = endpoint; +} diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptionsBuilder.cs b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptionsBuilder.cs new file mode 100644 index 0000000..7191d1a --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOfflineOptionsBuilder.cs @@ -0,0 +1,150 @@ +// 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.Http; + +namespace CommunityToolkit.Datasync.Client.Offline; + +/// +/// The options builder for the offline operations. +/// +public class DatasyncOfflineOptionsBuilder +{ + internal IHttpClientFactory? _httpClientFactory; + internal readonly Dictionary _entities; + + /// + /// Creates the builder based on the required entity types. + /// + /// The entity type list. + internal DatasyncOfflineOptionsBuilder(IEnumerable entityTypes) + { + this._entities = entityTypes.ToDictionary(x => x.FullName!, x => new EntityOfflineOptions(x)); + } + + /// + /// Sets the default mechanism for getting a to be + /// the specified . + /// + /// The to use. + /// The current builder for chaining. + public DatasyncOfflineOptionsBuilder UseHttpClientFactory(IHttpClientFactory httpClientFactory) + { + ArgumentNullException.ThrowIfNull(httpClientFactory, nameof(httpClientFactory)); + this._httpClientFactory = httpClientFactory; + return this; + } + + /// + /// Sets the default mechanism for getting a to be + /// a constant + /// + /// The to use. + /// The current builder for chaining. + public DatasyncOfflineOptionsBuilder UseHttpClient(HttpClient httpClient) + { + ArgumentNullException.ThrowIfNull(httpClient, nameof(httpClient)); + this._httpClientFactory = new BasicHttpClientFactory(httpClient); + return this; + } + + /// + /// Sets the default mechanism for getting a to be a + /// standard based on the provided endpoint. + /// + /// The pointing to the datasync endpoint. + /// The current builder for chaining. + public DatasyncOfflineOptionsBuilder UseEndpoint(Uri endpoint) + { + ArgumentNullException.ThrowIfNull(endpoint, nameof(endpoint)); + ThrowIf.IsNotValidEndpoint(endpoint, nameof(endpoint)); + this._httpClientFactory = new HttpClientFactory(new HttpClientOptions { Endpoint = endpoint }); + return this; + } + + /// + /// Sets the default mechanism for getting a to be a + /// standard based on the provided client options + /// + /// The pointing to the datasync endpoint. + /// The current builder for chaining. + public DatasyncOfflineOptionsBuilder UseHttpClientOptions(HttpClientOptions clientOptions) + { + ArgumentNullException.ThrowIfNull(clientOptions, nameof(clientOptions)); + this._httpClientFactory = new HttpClientFactory(clientOptions); + return this; + } + + /// + /// Configures the specified entity type for offline operations. + /// + /// The type of the entity. + /// A configuration function for the entity. + /// The current builder for chaining. + public DatasyncOfflineOptionsBuilder Entity(Action configure) + => Entity(typeof(TEntity), configure); + + /// + /// Configures the specified entity type for offline operations. + /// + /// The type of the entity. + /// A configuration function for the entity. + /// The current builder for chaining. + public DatasyncOfflineOptionsBuilder Entity(Type entityType, Action configure) + { + ArgumentNullException.ThrowIfNull(entityType, nameof(entityType)); + ArgumentNullException.ThrowIfNull(configure, nameof(configure)); + if (!this._entities.TryGetValue(entityType.FullName!, out EntityOfflineOptions? options)) + { + throw new DatasyncException($"Entity is not synchronizable."); + } + + configure(options); + return this; + } + + /// + /// Retrieves the offline options for the entity type. + /// + /// The entity type. + /// The offline options for the entity type. + internal DatasyncOfflineOptions GetOfflineOptions(Type entityType) + { + ArgumentNullException.ThrowIfNull(entityType, nameof(entityType)); + if (this._httpClientFactory == null) + { + throw new DatasyncException($"Datasync service connection is not set."); + } + + if (!this._entities.TryGetValue(entityType.FullName!, out EntityOfflineOptions? options)) + { + throw new DatasyncException($"Entity is not synchronizable."); + } + + HttpClient client = this._httpClientFactory.CreateClient(options.ClientName); + return new DatasyncOfflineOptions(client, options.Endpoint); + } + + /// + /// The entity offline options that are used for configuration. + /// + /// The entity type for this entity offline options. + public class EntityOfflineOptions(Type entityType) + { + /// + /// The entity type being configured. + /// + public Type EntityType { get => entityType; } + + /// + /// The endpoint for the entity type. + /// + public Uri Endpoint { get; set; } = new Uri($"/tables/{entityType.Name.ToLowerInvariant()}", UriKind.Relative); + + /// + /// The name of the client to use when requesting a . + /// + public string ClientName { get; set; } = string.Empty; + } +} diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs index 6ad5eb0..294ef1c 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs @@ -2,7 +2,10 @@ // 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 Microsoft.EntityFrameworkCore.Metadata.Internal; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; namespace CommunityToolkit.Datasync.Client.Offline; @@ -35,47 +38,72 @@ public enum OperationState /// /// An entity representing a pending operation against an entity set. /// +[Index(nameof(ItemId), nameof(EntityType))] public class DatasyncOperation { /// /// A unique ID for the operation. /// + [Key] public required string Id { get; set; } /// /// The kind of operation that this entity represents. /// + [Required] public required OperationKind Kind { get; set; } /// /// The current state of the operation. /// + [Required] public required OperationState State { get; set; } + /// + /// The date/time of the last attempt. + /// + public DateTimeOffset? LastAttempt { get; set; } + + /// + /// The HTTP Status Code for the last attempt. + /// + public int? HttpStatusCode { get; set; } + /// /// The fully qualified name of the entity type. /// + [Required, MaxLength(255)] public required string EntityType { get; set; } /// /// The globally unique ID of the entity. /// + [Required, MaxLength(126)] public required string ItemId { get; set; } + /// + /// The version of the entity currently downloaded from the service. + /// + [Required, MaxLength(126)] + public required string EntityVersion { get; set; } + /// /// The JSON-encoded representation of the Item. /// + [Required, DataType(DataType.Text)] public required string Item { get; set; } /// /// The sequence number for the operation. This is incremented for each /// new operation to a different entity. /// + [DefaultValue(0L)] public required long Sequence { get; set; } /// /// The version number for the operation. This is incremented as multiple /// changes to the same entity are performed in between pushes. /// + [DefaultValue(0)] public required int Version { get; set; } } diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DbSetExtensions.cs b/src/CommunityToolkit.Datasync.Client/Offline/DbSetExtensions.cs new file mode 100644 index 0000000..a6492a5 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/DbSetExtensions.cs @@ -0,0 +1,45 @@ +// 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 Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CommunityToolkit.Datasync.Client.Offline; + +/// +/// A set of extension methods when using offline to allow you to synchronize +/// a with a remote service. +/// +public static class DbSetExtensions +{ + /// + /// Pushes entities from the requested entity type to the remote service. + /// + /// The to use for synchronization. + /// A to observe. + /// The results of the push operation. + public static Task PushAsync(this DbSet set, CancellationToken cancellationToken = default) where TEntity : class + => PushAsync(set, new PushOptions(), cancellationToken); + + /// + /// Pushes entities from the requested entity type to the remote service. + /// + /// The to use for synchronization. + /// The options to use for this push operation. + /// A to observe. + /// The results of the push operation. + public static Task PushAsync(this DbSet set, PushOptions pushOptions, CancellationToken cancellationToken = default) where TEntity : class + { + ICurrentDbContext context = set.GetService(); + if (context.Context is OfflineDbContext offlineContext) + { + Type[] entityTypes = [typeof(TEntity)]; + return offlineContext.PushAsync(entityTypes, pushOptions, cancellationToken); + } + else + { + throw new DatasyncException("DbContext for this dataset is not an OfflineDbContext."); + } + } +} diff --git a/src/CommunityToolkit.Datasync.Client/Offline/ExecutableOperation.cs b/src/CommunityToolkit.Datasync.Client/Offline/ExecutableOperation.cs new file mode 100644 index 0000000..09313e6 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/ExecutableOperation.cs @@ -0,0 +1,154 @@ +// 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.Service; +using Microsoft.Extensions.Options; +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace CommunityToolkit.Datasync.Client.Offline; + +/// +/// The abstract class that encapsulates all logic dealing with pushing an operation to the remote server. +/// +internal abstract class ExecutableOperation +{ + /// + /// The JSON media type. + /// + internal MediaTypeHeaderValue JsonMediaType { get; } = MediaTypeHeaderValue.Parse("application/json"); + + /// + /// Converts a base address + relative/absolute URI into the appropriate URI for the datasync service. + /// + /// The base address from the client. + /// A relative or absolute URI + /// + internal static Uri MakeAbsoluteUri(Uri? baseAddress, Uri relativeOrAbsoluteUri) + { + if (relativeOrAbsoluteUri.IsAbsoluteUri) + { + return new Uri($"{relativeOrAbsoluteUri.ToString().TrimEnd('/')}/"); + } + + if (baseAddress != null) + { + if (baseAddress.IsAbsoluteUri) + { + return new Uri($"{new Uri(baseAddress, relativeOrAbsoluteUri).ToString().TrimEnd('/')}/"); + } + } + + throw new UriFormatException("Invalid combination of baseAddress and relativeUri"); + } + + /// + /// Performs the push operation, returning the result of the push operation. + /// + /// The to use for communicating with the datasync service. + /// The fully-qualified URI to the table endpoint. + /// A to observe. + /// The result of the push operation (async). + internal abstract Task ExecuteAsync(HttpClient client, Uri endpoint, CancellationToken cancellationToken = default); + +#pragma warning disable IDE0060 // Remove unused parameter - cancellationToken is kept for API consistency. + /// + /// Creates a new based on the operation model. + /// + /// The operation to execute. + /// A to observe. + /// The to execute. + /// If the operation Kind is not supported for push. + internal static Task CreateAsync(DatasyncOperation operation, CancellationToken cancellationToken = default) => operation.Kind switch + { + OperationKind.Add => Task.FromResult(new AddOperation(operation)), + OperationKind.Delete => Task.FromResult(new DeleteOperation(operation)), + OperationKind.Replace => Task.FromResult(new ReplaceOperation(operation)), + _ => throw new DatasyncException($"Invalid operation kind '{operation.Kind}'"), + }; +#pragma warning restore IDE0060 // Remove unused parameter +} + +/// +/// The executable operation for an "ADD" operation. +/// +/// The operation to execute. +internal class AddOperation(DatasyncOperation operation) : ExecutableOperation +{ + /// + /// Performs the push operation, returning the result of the push operation. + /// + /// The to use for communicating with the datasync service. + /// The fully-qualified URI to the table endpoint. + /// A to observe. + /// The result of the push operation (async). + internal override async Task ExecuteAsync(HttpClient client, Uri endpoint, CancellationToken cancellationToken = default) + { + endpoint = MakeAbsoluteUri(client.BaseAddress, endpoint); + using HttpRequestMessage request = new(HttpMethod.Post, endpoint) + { + Content = new StringContent(operation.Item, JsonMediaType) + }; + using HttpResponseMessage response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + return new ServiceResponse(response); + } +} + +/// +/// The executable operation for a "DELETE" operation. +/// +/// The operation to execute. +internal class DeleteOperation(DatasyncOperation operation) : ExecutableOperation +{ + /// + /// Performs the push operation, returning the result of the push operation. + /// + /// The to use for communicating with the datasync service. + /// The fully-qualified URI to the table endpoint. + /// A to observe. + /// The result of the push operation (async). + internal override async Task ExecuteAsync(HttpClient client, Uri endpoint, CancellationToken cancellationToken = default) + { + endpoint = MakeAbsoluteUri(client.BaseAddress, endpoint); + using HttpRequestMessage request = new(HttpMethod.Delete, new Uri(endpoint, operation.ItemId)); + if (!string.IsNullOrEmpty(operation.EntityVersion)) + { + request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{operation.EntityVersion}\"")); + } + + using HttpResponseMessage response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + return new ServiceResponse(response); + } +} + +/// +/// The executable operation for a "REPLACE" operation. +/// +/// The operation to execute. +internal class ReplaceOperation(DatasyncOperation operation) : ExecutableOperation +{ + /// + /// Performs the push operation, returning the result of the push operation. + /// + /// The to use for communicating with the datasync service. + /// The fully-qualified URI to the table endpoint. + /// A to observe. + /// The result of the push operation (async). + internal override async Task ExecuteAsync(HttpClient client, Uri endpoint, CancellationToken cancellationToken = default) + { + endpoint = MakeAbsoluteUri(client.BaseAddress, endpoint); + using HttpRequestMessage request = new(HttpMethod.Put, new Uri(endpoint, operation.ItemId)) + { + Content = new StringContent(operation.Item, JsonMediaType) + }; + if (!string.IsNullOrEmpty(operation.EntityVersion)) + { + request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{operation.EntityVersion}\"")); + } + + using HttpResponseMessage response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + return new ServiceResponse(response); + } +} diff --git a/src/CommunityToolkit.Datasync.Client/Offline/LockManager.cs b/src/CommunityToolkit.Datasync.Client/Offline/LockManager.cs new file mode 100644 index 0000000..589917d --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/LockManager.cs @@ -0,0 +1,37 @@ +// 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.Threading; + +namespace CommunityToolkit.Datasync.Client.Offline; + +/// +/// A manager for disposable locks that are used throughout the offline +/// synchronization process to ensure two threads don't work on the same +/// data at the same time. +/// +/// +/// A class for handling a dictionary of locks. +/// +internal static class LockManager +{ + /// + /// The name of the shared synchronization lock. + /// + internal const string synchronizationLockName = "synclock"; + + /// + /// The underlying async lock dictionary to use. + /// + internal static Lazy lockDictionary = new(() => new AsyncLockDictionary()); + + /// + /// Acquire a disposable lock from the dictionary of locks. + /// + /// The key to the lock. + /// A to observe. + /// An that can be used to release the lock. + internal static Task AcquireLockAsync(string key, CancellationToken cancellationToken) + => lockDictionary.Value.AcquireLockAsync(key, cancellationToken); +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs b/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs index 7b71090..d722027 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs @@ -2,11 +2,15 @@ // 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.Serialization; +using CommunityToolkit.Datasync.Client.Threading; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; namespace CommunityToolkit.Datasync.Client.Offline; @@ -54,11 +58,21 @@ public abstract class OfflineDbContext : DbContext /// internal bool _disposedValue; + /// + /// The value of the initialized offline options builder. + /// + internal DatasyncOfflineOptionsBuilder? offlineOptionsBuilder = null; + /// /// The operations queue manager. /// internal OperationsQueueManager QueueManager { get; } + /// + /// The lock for datasync initialization. + /// + internal DisposableLock DatasyncInitializationLock { get; } = new DisposableLock(); + /// /// The operations queue. This is used to store pending requests to the datasync service. /// @@ -168,6 +182,170 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) base.OnModelCreating(modelBuilder); } + /// + /// Override this method to configure the datasync service connections for each entity. + /// + /// + /// The builder being used to construct the datasync service options for this context. + /// + protected abstract void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder); + + /// + /// Retrieves the offline options to use for a specific entity type. + /// + /// The entity type. + /// The offline options to use for the entity type. + internal DatasyncOfflineOptions GetOfflineOptions(Type type) + { + ArgumentNullException.ThrowIfNull(type, nameof(type)); + using (DatasyncInitializationLock.AcquireLock()) + { + if (this.offlineOptionsBuilder == null) + { + QueueManager.InitializeEntityMap(); + this.offlineOptionsBuilder = new DatasyncOfflineOptionsBuilder(QueueManager.EntityMap.Values); + OnDatasyncInitialization(this.offlineOptionsBuilder); + } + } + + return this.offlineOptionsBuilder.GetOfflineOptions(type); + } + + /// + /// Pushes entities from the all synchronizable entity types to the remote service. + /// + /// A to observe. + /// The results of the push operation. + public Task PushAsync(CancellationToken cancellationToken = default) + => PushAsync(QueueManager.GetSynchronizableEntityTypes(), new PushOptions(), cancellationToken); + + /// + /// Pushes entities from the all synchronizable entity types to the remote service. + /// + /// The options to use for this push operation. + /// A to observe. + /// The results of the push operation. + [ExcludeFromCodeCoverage] + public Task PushAsync(PushOptions pushOptions, CancellationToken cancellationToken = default) + => PushAsync(QueueManager.GetSynchronizableEntityTypes(), pushOptions, cancellationToken); + + /// + /// Pushes entities from the selected entity types to the remote service. + /// + /// The entity types in scope for this push operation. + /// A to observe. + /// The results of the push operation. + [ExcludeFromCodeCoverage] + public Task PushAsync(IEnumerable entityTypes, CancellationToken cancellationToken = default) + => PushAsync(entityTypes, new PushOptions(), cancellationToken); + + /// + /// Pushes entities from the selected entity types to the remote service. + /// + /// The entity types in scope for this push operation. + /// The options to use for this push operation. + /// A to observe. + /// The results of the push operation. + public virtual async Task PushAsync(IEnumerable entityTypes, PushOptions pushOptions, CancellationToken cancellationToken = default) + { + ArgumentValidationException.ThrowIfNotValid(pushOptions, nameof(pushOptions)); + + if (pushOptions.AutoSave) + { + // Make sure the queued entities are up to date. + _ = SaveChanges(acceptAllChangesOnSuccess: true); + } + + using IDisposable syncLock = await LockManager.AcquireLockAsync(LockManager.synchronizationLockName, cancellationToken).ConfigureAwait(false); + + // Gets the list of EntityTypeNames for the synchronizable entities in the list. + List entityTypeNames = QueueManager.GetSynchronizableEntityTypes(entityTypes).Select(t => t.FullName!).ToList(); + if (entityTypeNames.Count == 0) + { + throw new DatasyncException("Provide at least one synchronizable entity type for push operations."); + } + + // Gets the list of queued operations that are in-scope + List queuedOperations = await DatasyncOperationsQueue + .Where(x => entityTypeNames.Contains(x.EntityType) && x.State != OperationState.Completed) + .ToListAsync(cancellationToken).ConfigureAwait(false); + if (queuedOperations.Count == 0) + { + return new PushOperationResult(); + } + + // Pushes the list of queued operations to the remote service. + PushOperationResult pushResult = new(); + QueueHandler queueHandler = new(pushOptions.ParallelOperations, async (operation) => + { + ServiceResponse? response = await PushOperationAsync(operation, cancellationToken).ConfigureAwait(false); + pushResult.AddOperationResult(operation.Id, response); + }); + queueHandler.EnqueueRange(queuedOperations); + await queueHandler.WhenComplete(); + + if (pushOptions.AutoSave) + { + _ = SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + } + + return pushResult; + } + + /// + /// Pushes a single operation in the context of a lock, updating the database at the same time. + /// + /// The operation to execute. + /// A to observe. + /// The result of the operation. + internal async Task PushOperationAsync(DatasyncOperation operation, CancellationToken cancellationToken = default) + { + string lockId = $"push-op-{operation.Id}"; + using IDisposable operationLock = await LockManager.AcquireLockAsync(lockId, cancellationToken).ConfigureAwait(false); + + Type entityType = QueueManager.GetSynchronizableEntityType(operation.EntityType) + ?? throw new DatasyncException($"Type '{operation.EntityType}' is not a synchronizable type."); + DatasyncOfflineOptions options = GetOfflineOptions(entityType); + + ExecutableOperation op = await ExecutableOperation.CreateAsync(operation, cancellationToken).ConfigureAwait(false); + ServiceResponse response = await op.ExecuteAsync(options.HttpClient, options.Endpoint, cancellationToken).ConfigureAwait(false); + + if (response.IsSuccessful) + { + if (operation.Kind != OperationKind.Delete) + { + object? newValue = JsonSerializer.Deserialize(response.ContentStream, entityType, DatasyncSerializer.JsonSerializerOptions); + object? oldValue = await FindAsync(entityType, [ operation.ItemId ], cancellationToken).ConfigureAwait(false); + ReplaceDatabaseValue(oldValue, newValue); + } + + _ = DatasyncOperationsQueue.Remove(operation); + return null; + } + + operation.LastAttempt = DateTimeOffset.UtcNow; + operation.HttpStatusCode = response.StatusCode; + operation.State = OperationState.Failed; + _ = Update(operation); + return response; + } + + /// + /// Internal helper - replaces an old value of an entity in the database with a new value. + /// + /// The old value. + /// The new value. + internal void ReplaceDatabaseValue(object? oldValue, object? newValue) + { + if (oldValue is null || newValue is null) + { + throw new DatasyncException("Internal Datasync Error: invalid values for replacement."); + } + + EntityEntry tracker = Entry(oldValue); + tracker.CurrentValues.SetValues(newValue); + } + /// /// Saves all changes made in this context to the database. /// @@ -415,9 +593,6 @@ public async Task SaveChangesAsync(bool acceptAllChangesOnSuccess, bool add return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken).ConfigureAwait(false); } - #region SaveChanges without adding to queue - #endregion - #region IDisposable /// /// Ensure that the context has not been disposed. diff --git a/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueueManager.cs b/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueueManager.cs index 6812141..b1b5d2a 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueueManager.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueueManager.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Datasync.Client.Serialization; +using CommunityToolkit.Datasync.Client.Threading; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -27,7 +28,12 @@ internal class OperationsQueueManager(OfflineDbContext context) : IDisposable /// /// The internal entity map for mapping entities that have been determined to be valid synchronization targets. /// - internal Dictionary DatasyncEntityMap { get; } = []; + internal Dictionary EntityMap { get; } = []; + + /// + /// The lock for writing to the entity map. + /// + private readonly DisposableLock entityMapLock = new(); /// /// The JSON Serializer Options to use in serializing and deserializing content. @@ -56,7 +62,39 @@ internal class OperationsQueueManager(OfflineDbContext context) : IDisposable } /// - /// Initializes the value of the - this provides the mapping + /// Returns the list of types that are "synchronizable". + /// + /// The list of allowed synchronizable types. + internal IEnumerable GetSynchronizableEntityTypes() + { + InitializeEntityMap(); + return EntityMap.Values; + } + + /// + /// Returns the list of types from the allowed types that are "synchronizable". + /// + /// The list of allowed types. + /// The list of allowed synchronizable types. + internal IEnumerable GetSynchronizableEntityTypes(IEnumerable allowedTypes) + { + InitializeEntityMap(); + return allowedTypes.Where(x => EntityMap.ContainsValue(x)); + } + + /// + /// Returns the associated type for the operation queue name. + /// + /// The name of the type. + /// The type. + internal Type? GetSynchronizableEntityType(string fullName) + { + InitializeEntityMap(); + return EntityMap.TryGetValue(fullName, out Type? entityType) ? entityType : null; + } + + /// + /// Initializes the value of the - this provides the mapping /// of entity name to type, which is required for operating the operations queue. /// /// @@ -68,26 +106,31 @@ internal class OperationsQueueManager(OfflineDbContext context) : IDisposable /// * The entity type is defined in the model. /// * The entity type has an Id, UpdatedAt, and Version property (according to the ). /// - internal void InitializeDatasyncEntityMap() + internal void InitializeEntityMap() { - if (DatasyncEntityMap.Count > 0) + if (EntityMap.Count > 0) { - // Fast return if the entity map has already been primed. return; } - Type[] modelEntities = context.Model.GetEntityTypes().Select(m => m.ClrType).ToArray(); - Type[] synchronizableEntities = context.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(IsSynchronizationEntity) - .Select(p => p.PropertyType.GetGenericArguments()[0]) - .ToArray(); - foreach (Type entityType in synchronizableEntities) + using (this.entityMapLock.AcquireLock()) { - DatasyncException.ThrowIfNullOrEmpty(entityType.FullName, $"Offline entity {entityType.Name} must be a valid reference type."); - EntityResolver.EntityPropertyInfo propInfo = EntityResolver.GetEntityPropertyInfo(entityType); - DatasyncException.ThrowIfNull(propInfo.UpdatedAtPropertyInfo, $"Offline entity {entityType.Name} does not have an UpdatedAt property."); - DatasyncException.ThrowIfNull(propInfo.VersionPropertyInfo, $"Offline entity {entityType.Name} does not have a Version property."); - DatasyncEntityMap.Add(entityType.FullName!, entityType); + if (EntityMap.Count == 0) + { + Type[] modelEntities = context.Model.GetEntityTypes().Select(m => m.ClrType).ToArray(); + Type[] synchronizableEntities = context.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(IsSynchronizationEntity) + .Select(p => p.PropertyType.GetGenericArguments()[0]) + .ToArray(); + foreach (Type entityType in synchronizableEntities) + { + DatasyncException.ThrowIfNullOrEmpty(entityType.FullName, $"Offline entity {entityType.Name} must be a valid reference type."); + EntityResolver.EntityPropertyInfo propInfo = EntityResolver.GetEntityPropertyInfo(entityType); + DatasyncException.ThrowIfNull(propInfo.UpdatedAtPropertyInfo, $"Offline entity {entityType.Name} does not have an UpdatedAt property."); + DatasyncException.ThrowIfNull(propInfo.VersionPropertyInfo, $"Offline entity {entityType.Name} does not have a Version property."); + EntityMap.Add(entityType.FullName!, entityType); + } + } } } @@ -217,7 +260,10 @@ public void UpdateOperationsQueue() public async Task UpdateOperationsQueueAsync(CancellationToken cancellationToken = default) { CheckDisposed(); - InitializeDatasyncEntityMap(); + + using IDisposable syncLock = await LockManager.AcquireLockAsync(LockManager.synchronizationLockName, cancellationToken).ConfigureAwait(false); + + InitializeEntityMap(); if (context.ChangeTracker.AutoDetectChangesEnabled) { @@ -227,7 +273,7 @@ public async Task UpdateOperationsQueueAsync(CancellationToken cancellationToken // Get the list of relevant changes from the change tracker: List entitiesInScope = context.ChangeTracker.Entries() .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) - .Where(e => DatasyncEntityMap.ContainsKey(NullAsEmpty(e.Entity.GetType().FullName))) + .Where(e => EntityMap.ContainsKey(NullAsEmpty(e.Entity.GetType().FullName))) .ToList(); // Get the current sequence ID. Note that ORDERBY/TOP is generally faster than aggregate functions in databases. @@ -254,6 +300,7 @@ public async Task UpdateOperationsQueueAsync(CancellationToken cancellationToken State = OperationState.Pending, EntityType = NullAsEmpty(entityType.FullName), ItemId = metadata.Id!, + EntityVersion = metadata.Version ?? string.Empty, Item = JsonSerializer.Serialize(entry.Entity, entityType, JsonSerializerOptions), Sequence = sequenceId, Version = 0 @@ -300,7 +347,7 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - // Remove any managed content here. + this.entityMapLock.Dispose(); } this._disposedValue = true; diff --git a/src/CommunityToolkit.Datasync.Client/Offline/PushOperationResult.cs b/src/CommunityToolkit.Datasync.Client/Offline/PushOperationResult.cs new file mode 100644 index 0000000..5cbe8e5 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/PushOperationResult.cs @@ -0,0 +1,49 @@ +// 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 System.Collections.Concurrent; +using System.Collections.Immutable; + +namespace CommunityToolkit.Datasync.Client.Offline; + +/// +/// The result of a single push operation. +/// +public class PushOperationResult +{ + private int _completedOperations = 0; + private readonly ConcurrentDictionary _failedOperations = new(); + + /// + /// The number of completed operations. + /// + public int CompletedOperations { get => this._completedOperations; } + + /// + /// The number of failed operations. + /// + public IDictionary FailedOperations { get => this._failedOperations.ToImmutableDictionary(); } + + /// + /// Determines if the operation was successful. + /// + public bool IsSuccessful { get => this._failedOperations.IsEmpty; } + + /// + /// Adds an operation result in a thread safe manner. + /// + /// The ID of the operation being processed. + /// The response from the service. + internal void AddOperationResult(string operationId, ServiceResponse? response) + { + if (response is null) + { + _ = Interlocked.Increment(ref this._completedOperations); + } + else + { + _ = this._failedOperations.TryAdd(operationId, response); + } + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Client/Offline/PushOptions.cs b/src/CommunityToolkit.Datasync.Client/Offline/PushOptions.cs new file mode 100644 index 0000000..af41813 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/PushOptions.cs @@ -0,0 +1,31 @@ +// 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 System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; + +namespace CommunityToolkit.Datasync.Client.Offline; + +/// +/// The options to use for pushing operations to the service. +/// +public class PushOptions +{ + /// + /// The number of parallel operations to use for the push operation. + /// + [Required, Range(1, 8, ErrorMessage = "Invalid number of parallel operations.")] + public int ParallelOperations { get; set; } = 1; + + /// + /// If set, all changes from a push operation are automatically saved at + /// the end of the push operations. + /// + /// + /// Bad things happen if you don't autosave - only do this if you are testing + /// specific locking and threading issues. + /// + [ExcludeFromCodeCoverage] + internal bool AutoSave { get; set; } = true; +} diff --git a/src/CommunityToolkit.Datasync.Client/Serialization/DatasyncSerializer.cs b/src/CommunityToolkit.Datasync.Client/Serialization/DatasyncSerializer.cs index c686ac4..7b3b45d 100644 --- a/src/CommunityToolkit.Datasync.Client/Serialization/DatasyncSerializer.cs +++ b/src/CommunityToolkit.Datasync.Client/Serialization/DatasyncSerializer.cs @@ -18,6 +18,24 @@ internal static class DatasyncSerializer /// internal static JsonSerializerOptions JsonSerializerOptions { get => _initializer.Value; } + /// + /// Serializes an object using the serializer options. + /// + /// The type of the object. + /// The object. + /// The serialized version of the object. + internal static string Serialize(T obj) + => JsonSerializer.Serialize(obj, JsonSerializerOptions); + + /// + /// Serializes an object using the serializer options. + /// + /// The object. + /// The type of the object. + /// The serialized version of the object. + internal static string Serialize(object obj, Type objType) + => JsonSerializer.Serialize(obj, objType, JsonSerializerOptions); + /// /// Internal method to create a new object for serializing and deserializing /// content in the service. You should never have to call this. diff --git a/src/CommunityToolkit.Datasync.Client/Threading/AsyncLockDictionary.cs b/src/CommunityToolkit.Datasync.Client/Threading/AsyncLockDictionary.cs new file mode 100644 index 0000000..40be358 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Threading/AsyncLockDictionary.cs @@ -0,0 +1,102 @@ +// 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 System.Collections.Concurrent; + +namespace CommunityToolkit.Datasync.Client.Threading; + +/// +/// A class for handling a dictionary of locks. +/// +internal class AsyncLockDictionary +{ + /// + /// The dictionary of locks. + /// + private readonly ConcurrentDictionary locks = []; + + /// + /// Acquire a lock (asynchronously) from the dictionary of locks. + /// + /// The key for the lock. + /// A to observe. + /// An to release the lock when done. + public async Task AcquireLockAsync(string key, CancellationToken cancellationToken = default) + { + LockEntry entry = this.locks.GetOrAdd(key, _ => new LockEntry()); + _ = entry.IncrementCount(); + + IDisposable releaser = await entry.Lock.AcquireLockAsync(cancellationToken).ConfigureAwait(false); + return new DisposeAction(() => + { + releaser.Dispose(); + if (entry.DecrementCount() == 0) + { + _ = this.locks.TryRemove(key, out _); + entry.Dispose(); + } + }); + } + + /// + /// Acquire a lock (asynchronously) from the dictionary of locks. + /// + /// The key for the lock. + /// An to release the lock when done. + public IDisposable AcquireLock(string key) + { + LockEntry entry = this.locks.GetOrAdd(key, _ => new LockEntry()); + _ = entry.IncrementCount(); + + IDisposable releaser = entry.Lock.AcquireLock(); + return new DisposeAction(() => + { + releaser.Dispose(); + if (entry.DecrementCount() == 0) + { + _ = this.locks.TryRemove(key, out _); + entry.Dispose(); + } + }); + } + + /// + /// Determines if the specific lock referenced by key is locked now. + /// + /// The key to check. + /// true if locked; false otherwise. + public bool IsLocked(string key) + { + if (this.locks.TryGetValue(key, out LockEntry? entry)) + { + return entry.IsLocked(); + } + + return false; + } + + /// + /// A single entry in the dictionary of locks. + /// + private sealed class LockEntry : IDisposable + { + private int _count = 0; + + public DisposableLock Lock { get; } = new(); + + public int IncrementCount() + => Interlocked.Increment(ref this._count); + + public int DecrementCount() + => Interlocked.Decrement(ref this._count); + + internal bool IsLocked() + => Lock.IsLocked(); + + public void Dispose() + { + Lock.Dispose(); + } + } +} diff --git a/src/CommunityToolkit.Datasync.Client/Threading/DisposableLock.cs b/src/CommunityToolkit.Datasync.Client/Threading/DisposableLock.cs new file mode 100644 index 0000000..5131439 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Threading/DisposableLock.cs @@ -0,0 +1,52 @@ +// 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. + +namespace CommunityToolkit.Datasync.Client.Threading; + +/// +/// A lock class that can be disposed. +/// +internal class DisposableLock : IDisposable +{ + /// + /// The underlying lock. + /// + private readonly SemaphoreSlim semaphore = new(1, 1); + + /// + /// Acquire the lock. + /// + /// An to release the lock when the lock is acquired. + public IDisposable AcquireLock() + { + this.semaphore.Wait(); + return new DisposeAction(() => this.semaphore.Release()); + } + + /// + /// Acquire the lock. + /// + /// + /// A task that returns an to release the lock when the lock is acquired. + public async Task AcquireLockAsync(CancellationToken cancellationToken = default) + { + await this.semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + return new DisposeAction(() => this.semaphore.Release()); + } + + /// + /// Determines if the disposable lock is locked or not. + /// + /// true if the disposable lock is locked; false otherwise. + public bool IsLocked() + => this.semaphore.CurrentCount == 0; + + /// + /// Disposes of the lock. + /// + public void Dispose() + { + this.semaphore.Dispose(); + } +} diff --git a/src/CommunityToolkit.Datasync.Client/Threading/DisposeAction.cs b/src/CommunityToolkit.Datasync.Client/Threading/DisposeAction.cs new file mode 100644 index 0000000..da90b7a --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Threading/DisposeAction.cs @@ -0,0 +1,27 @@ +// 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. + +namespace CommunityToolkit.Datasync.Client.Threading; + +/// +/// An that runs an action on disposing. +/// +/// +/// This is most often used to release an asynchronous lock when disposed. +/// +internal struct DisposeAction(Action action) : IDisposable +{ + bool isDisposed = false; + + public void Dispose() + { + if (this.isDisposed) + { + return; + } + + this.isDisposed = true; + action(); + } +} diff --git a/src/CommunityToolkit.Datasync.Client/Threading/QueueHandler.cs b/src/CommunityToolkit.Datasync.Client/Threading/QueueHandler.cs new file mode 100644 index 0000000..2cbe532 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Threading/QueueHandler.cs @@ -0,0 +1,66 @@ +// 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 System.Threading.Tasks.Dataflow; + +namespace CommunityToolkit.Datasync.Client.Threading; + +/// +/// A parallel async queue runner. +/// +/// The type of the argument to the queue runner +internal class QueueHandler where T : class +{ + private readonly ActionBlock jobs; + + /// + /// Creates a new + /// + /// The maximum number of threads to use. + /// The job runner. + public QueueHandler(int maxThreads, Func jobRunner) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maxThreads, 1, nameof(maxThreads)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(maxThreads, 8, nameof(maxThreads)); + ArgumentNullException.ThrowIfNull(jobRunner, nameof(jobRunner)); + ExecutionDataflowBlockOptions options = new() { MaxDegreeOfParallelism = maxThreads }; + this.jobs = new(jobRunner, options); + } + + /// + /// Enqueues a new job. + /// + /// The entity to be queued up for running. + public void Enqueue(T item) + { + _ = this.jobs.Post(item); + } + + /// + /// Enqueues a list of jobs. + /// + /// The entities to be queued up for running. + public void EnqueueRange(IEnumerable items) + { + foreach (T item in items) + { + Enqueue(item); + } + } + + /// + /// Returns a task that resolves when all the runners are completed. + /// + /// + public Task WhenComplete() + { + this.jobs.Complete(); + return this.jobs.Completion; + } + + /// + /// The number of items still to be processed. + /// + public int Count { get => this.jobs.InputCount; } +} diff --git a/src/CommunityToolkit.Datasync.Server.NSwag/GlobalSuppressions.cs b/src/CommunityToolkit.Datasync.Server.NSwag/GlobalSuppressions.cs deleted file mode 100644 index df78d68..0000000 --- a/src/CommunityToolkit.Datasync.Server.NSwag/GlobalSuppressions.cs +++ /dev/null @@ -1,8 +0,0 @@ -// This file is used by Code Analysis to maintain SuppressMessage -// attributes that are applied to this project. -// Project-level suppressions either have no target or are given -// a specific target and scoped to a namespace, type, member, etc. - -using System.Diagnostics.CodeAnalysis; - -[assembly: SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "", Scope = "member", Target = "~M:CommunityToolkit.Datasync.Server.NSwag.OpenApiDatasyncExtensions.SetResponse(NSwag.OpenApiOperation,System.Net.HttpStatusCode,NJsonSchema.JsonSchema,System.Boolean)")] diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Exceptions/ArgumentValidationException_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Exceptions/ArgumentValidationException_Tests.cs new file mode 100644 index 0000000..fe9cdd3 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Exceptions/ArgumentValidationException_Tests.cs @@ -0,0 +1,55 @@ +// 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 System.ComponentModel.DataAnnotations; + +namespace CommunityToolkit.Datasync.Client.Test.Exceptions; + +[ExcludeFromCodeCoverage] +public class ArgumentValidationException_Tests +{ + [Fact] + public void Validation_Pass() + { + TestObject obj = new() { Value = 1 }; + Action act = () => ArgumentValidationException.ThrowIfNotValid(obj, nameof(obj)); + act.Should().NotThrow(); + } + + [Fact] + public void Validation_Throws() + { + TestObject obj = new() { Value = 0 }; + Action act = () => ArgumentValidationException.ThrowIfNotValid(obj, nameof(obj)); + ArgumentValidationException ex = act.Should().Throw().Subject.First(); + ex.Message.Should().Be("Object is not valid (Parameter 'obj')"); + ex.ValidationErrors.Should().HaveCount(1); + ex.ValidationErrors[0].ErrorMessage.Should().Be("Foo"); + } + + [Fact] + public void Validation_Throws_CustomMessage() + { + TestObject obj = new() { Value = 0 }; + Action act = () => ArgumentValidationException.ThrowIfNotValid(obj, nameof(obj), "custom message"); + ArgumentValidationException ex = act.Should().Throw().Subject.First(); + ex.Message.Should().Be("custom message (Parameter 'obj')"); + ex.ValidationErrors.Should().HaveCount(1); + ex.ValidationErrors[0].ErrorMessage.Should().Be("Foo"); + } + + [Fact] + public void Validation_Throws_Null() + { + TestObject obj = null; + Action act = () => ArgumentValidationException.ThrowIfNotValid(obj, nameof(obj)); + act.Should().Throw(); + } + + internal class TestObject + { + [Range(1, 8, ErrorMessage = "Foo")] + public int Value { get; set; } = 1; + } +} diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/DatasyncOfflineOptionsBuilder_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/DatasyncOfflineOptionsBuilder_Tests.cs new file mode 100644 index 0000000..51a35f8 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/DatasyncOfflineOptionsBuilder_Tests.cs @@ -0,0 +1,214 @@ +// 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.Http; +using CommunityToolkit.Datasync.Client.Offline; +using CommunityToolkit.Datasync.Client.Test.Helpers; +using CommunityToolkit.Datasync.TestCommon.Databases; +using NSubstitute; + +namespace CommunityToolkit.Datasync.Client.Test.Offline; + +[ExcludeFromCodeCoverage] +public class DatasyncOfflineOptionsBuilder_Tests +{ + [Fact] + public void UseHttpClientFactory() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + BasicHttpClientFactory clientFactory = new(new HttpClient()); + + sut.UseHttpClientFactory(clientFactory); + sut._httpClientFactory.Should().BeSameAs(clientFactory); + } + + [Fact] + public void UseHttpClientFactory_Null() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + Action act = () => _ = sut.UseHttpClientFactory(null); + act.Should().Throw(); + } + + [Fact] + public void UseHttpClient() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + HttpClient client = new(); + + sut.UseHttpClient(client); + HttpClient actual = sut._httpClientFactory.CreateClient(); + actual.Should().BeSameAs(client); + } + + [Fact] + public void UseHttpClient_Null() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + Action act = () => _ = sut.UseHttpClient(null); + act.Should().Throw(); + } + + [Fact] + public void UseEndpoint() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + Uri endpoint = new("http://localhost"); + + sut.UseEndpoint(endpoint); + HttpClient actual = sut._httpClientFactory.CreateClient(); + actual.BaseAddress.ToString().Should().Be("http://localhost/"); + } + + [Theory, MemberData(nameof(EndpointTestCases.InvalidEndpointTestCases), MemberType = typeof(EndpointTestCases))] + public void UseEndpoint_Invalid(Uri endpoint) + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + + Action act = () => _ = sut.UseEndpoint(endpoint); + act.Should().Throw(); + } + + [Fact] + public void UseHttpClientOptions() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + HttpClientOptions options = new() { Endpoint = new Uri("http://localhost") }; + + sut.UseHttpClientOptions(options); + HttpClient actual = sut._httpClientFactory.CreateClient(); + actual.BaseAddress.ToString().Should().Be("http://localhost/"); + } + + [Fact] + public void UseHttpClientOptions_Null() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + + Action act = () => _ = sut.UseHttpClientOptions(null); + act.Should().Throw(); + } + + [Fact] + public void Entity_Generic() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + string typeName = typeof(ClientMovie).FullName; + + _ = sut.Entity(options => + { + options.ClientName = "foo"; + options.Endpoint = new Uri("/bar", UriKind.Relative); + }); + + sut._entities[typeName].EntityType.Should().Be(typeof(ClientMovie)); + sut._entities[typeName].ClientName.Should().Be("foo"); + sut._entities[typeName].Endpoint.ToString().Should().Be("/bar"); + } + + [Fact] + public void Entity_Generic_Unknown() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + string typeName = typeof(ClientMovie).FullName; + + Action act = () => _ = sut.Entity(options => { /* Do Nothing */ }); + act.Should().Throw(); + } + + [Fact] + public void Entity_Arg() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + string typeName = typeof(ClientMovie).FullName; + + _ = sut.Entity(typeof(ClientMovie), options => + { + options.ClientName = "foo"; + options.Endpoint = new Uri("/bar", UriKind.Relative); + }); + + sut._entities[typeName].EntityType.Should().Be(typeof(ClientMovie)); + sut._entities[typeName].ClientName.Should().Be("foo"); + sut._entities[typeName].Endpoint.ToString().Should().Be("/bar"); + } + + [Fact] + public void Entity_Arg_Unknown() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + string typeName = typeof(ClientMovie).FullName; + + Action act = () => _ = sut.Entity(typeof(InMemoryMovie), options => { /* Do Nothing */ }); + act.Should().Throw(); + } + + [Fact] + public void GetOfflineOptions_NoClientFactory() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + + Action act = () => _ = sut.GetOfflineOptions(typeof(ClientMovie)); + act.Should().Throw(); + } + + [Fact] + public void GetOfflineOptions_InvalidEntityType() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + sut.UseEndpoint(new Uri("http://localhost")); + + Action act = () => _ = sut.GetOfflineOptions(typeof(InMemoryMovie)); + act.Should().Throw(); + } + + [Fact] + public void GetOfflineOptions_Defaults() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + HttpClient client = new(); + + sut.UseHttpClient(client); + DatasyncOfflineOptions options = sut.GetOfflineOptions(typeof(ClientMovie)); + options.Endpoint.ToString().Should().Be("/tables/clientmovie"); + options.HttpClient.Should().BeSameAs(client); + } + + [Fact] + public void GetOfflineOptions_Sets() + { + Type[] entityTypes = [typeof(ClientMovie), typeof(ClientKitchenSink)]; + DatasyncOfflineOptionsBuilder sut = new(entityTypes); + HttpClient client = new(); + IHttpClientFactory factory = Substitute.For(); + factory.CreateClient(Arg.Any()).Returns(client); + + sut.UseHttpClientFactory(factory).Entity(opt => + { + opt.ClientName = "foo"; + opt.Endpoint = new Uri("http://localhost/foo"); + }); + + DatasyncOfflineOptions options = sut.GetOfflineOptions(typeof(ClientMovie)); + options.HttpClient.Should().NotBeNull(); + options.Endpoint.ToString().Should().Be("http://localhost/foo"); + + factory.Received().CreateClient("foo"); + } +} diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/DbSetExtension_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/DbSetExtension_Tests.cs new file mode 100644 index 0000000..da20da4 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/DbSetExtension_Tests.cs @@ -0,0 +1,284 @@ +// 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.Serialization; +using CommunityToolkit.Datasync.Client.Test.Offline.Helpers; +using CommunityToolkit.Datasync.TestCommon.Databases; +using System.Net; +using System.Text; + +using TestData = CommunityToolkit.Datasync.TestCommon.TestData; + +namespace CommunityToolkit.Datasync.Client.Test.Offline; + +[ExcludeFromCodeCoverage] +public class DbSetExtension_Tests : BaseTest +{ + #region PushAsync + [Fact] + public async void PushAsync_Addition_Works() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string clientMovieJson = DatasyncSerializer.Serialize(clientMovie); + Type[] entityTypes = [typeof(ClientMovie)]; + + context.Movies.Add(clientMovie); + context.SaveChanges(); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + PushOperationResult results = await context.Movies.PushAsync(); + results.IsSuccessful.Should().BeTrue(); + results.CompletedOperations.Should().Be(1); + results.FailedOperations.Should().BeEmpty(); + + context.DatasyncOperationsQueue.Should().BeEmpty(); + + ClientMovie actualMovie = context.Movies.SingleOrDefault(x => x.Id == clientMovie.Id); + actualMovie.UpdatedAt!.Should().BeCloseTo((DateTimeOffset)responseMovie.UpdatedAt, TimeSpan.FromMicroseconds(1000)); + actualMovie.Version.Should().Be(responseMovie.Version); + } + + [Fact] + public async Task PushAsync_Addition_HttpError() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + + context.Movies.Add(clientMovie); + context.SaveChanges(); + + context.Handler.AddResponse(HttpStatusCode.InternalServerError); + + PushOperationResult results = await context.Movies.PushAsync(); + results.IsSuccessful.Should().BeFalse(); + results.CompletedOperations.Should().Be(0); + results.FailedOperations.Should().HaveCount(1); + ServiceResponse result = results.FailedOperations.First().Value; + result.StatusCode.Should().Be(500); + result.HasContent.Should().BeFalse(); + + context.DatasyncOperationsQueue.Should().HaveCount(1); + } + + [Fact] + public async Task PushAsync_Addition_Conflict() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + + context.Movies.Add(clientMovie); + context.SaveChanges(); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + PushOperationResult results = await context.Movies.PushAsync(); + results.IsSuccessful.Should().BeFalse(); + results.CompletedOperations.Should().Be(0); + results.FailedOperations.Should().HaveCount(1); + ServiceResponse result = results.FailedOperations.First().Value; + result.StatusCode.Should().Be(409); + result.HasContent.Should().BeTrue(); + string content = new StreamReader(result.ContentStream).ReadToEnd(); + content.Should().Be(expectedJson); + + context.DatasyncOperationsQueue.Should().HaveCount(1); + } + + [Fact] + public async Task PushAsync_Removal_Works() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + context.Handler.AddResponse(HttpStatusCode.NoContent); + + PushOperationResult results = await context.Movies.PushAsync(); + results.IsSuccessful.Should().BeTrue(); + results.CompletedOperations.Should().Be(1); + results.FailedOperations.Should().BeEmpty(); + + context.DatasyncOperationsQueue.Should().BeEmpty(); + context.Movies.Find(clientMovie.Id).Should().BeNull(); + } + + [Fact] + public async Task PushAsync_Removal_HttpError() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + context.Handler.AddResponse(HttpStatusCode.InternalServerError); + + PushOperationResult results = await context.Movies.PushAsync(); + results.IsSuccessful.Should().BeFalse(); + results.CompletedOperations.Should().Be(0); + results.FailedOperations.Should().HaveCount(1); + ServiceResponse result = results.FailedOperations.First().Value; + result.StatusCode.Should().Be(500); + result.HasContent.Should().BeFalse(); + + context.DatasyncOperationsQueue.Should().HaveCount(1); + } + + [Fact] + public async Task PushAsync_Removal_Conflict() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + PushOperationResult results = await context.Movies.PushAsync(); + results.IsSuccessful.Should().BeFalse(); + results.CompletedOperations.Should().Be(0); + results.FailedOperations.Should().HaveCount(1); + ServiceResponse result = results.FailedOperations.First().Value; + result.StatusCode.Should().Be(409); + result.HasContent.Should().BeTrue(); + string content = new StreamReader(result.ContentStream).ReadToEnd(); + content.Should().Be(expectedJson); + + context.DatasyncOperationsQueue.Should().HaveCount(1); + } + + [Fact] + public async Task PushAsync_Replacement_Works() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Foo"; + context.Update(clientMovie); + context.SaveChanges(); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + PushOperationResult results = await context.Movies.PushAsync(); + results.IsSuccessful.Should().BeTrue(); + results.CompletedOperations.Should().Be(1); + results.FailedOperations.Should().BeEmpty(); + + context.DatasyncOperationsQueue.Should().BeEmpty(); + } + + [Fact] + public async Task PushAsync_Replacement_HttpError() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Foo"; + context.Update(clientMovie); + context.SaveChanges(); + + context.Handler.AddResponse(HttpStatusCode.InternalServerError); + + PushOperationResult results = await context.Movies.PushAsync(); + results.IsSuccessful.Should().BeFalse(); + results.CompletedOperations.Should().Be(0); + results.FailedOperations.Should().HaveCount(1); + ServiceResponse result = results.FailedOperations.First().Value; + result.StatusCode.Should().Be(500); + result.HasContent.Should().BeFalse(); + + context.DatasyncOperationsQueue.Should().HaveCount(1); + } + + [Fact] + public async Task PushAsync_Replacment_Conflict() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Foo"; + context.Update(clientMovie); + context.SaveChanges(); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + PushOperationResult results = await context.Movies.PushAsync(); + results.IsSuccessful.Should().BeFalse(); + results.CompletedOperations.Should().Be(0); + results.FailedOperations.Should().HaveCount(1); + ServiceResponse result = results.FailedOperations.First().Value; + result.StatusCode.Should().Be(409); + result.HasContent.Should().BeTrue(); + string content = new StreamReader(result.ContentStream).ReadToEnd(); + content.Should().Be(expectedJson); + + context.DatasyncOperationsQueue.Should().HaveCount(1); + } + #endregion + + [Fact] + public async Task PushAsync_Throws_NonOffline() + { + SqliteDbContext context = SqliteDbContext.CreateContext(); + Func act = async () => _ = await context.Movies.PushAsync(); + await act.Should().ThrowAsync(); + } +} diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/ExecutableOperation_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/ExecutableOperation_Tests.cs new file mode 100644 index 0000000..55f391b --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/ExecutableOperation_Tests.cs @@ -0,0 +1,280 @@ +// 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.Serialization; +using CommunityToolkit.Datasync.Client.Test.Offline.Helpers; +using CommunityToolkit.Datasync.TestCommon; +using CommunityToolkit.Datasync.TestCommon.Databases; +using CommunityToolkit.Datasync.TestCommon.Mocks; +using System.Net; +using System.Text.Json; +using TestData = CommunityToolkit.Datasync.TestCommon.TestData; + +namespace CommunityToolkit.Datasync.Client.Test.Offline; + +[ExcludeFromCodeCoverage] +public class ExecutableOperation_Tests +{ + [Fact] + public async Task CreateAsync_InvalidKind() + { + DatasyncOperation op = new() + { + Id = Guid.NewGuid().ToString(), + Kind = (OperationKind)9999, + State = OperationState.Pending, + EntityType = string.Empty, + ItemId = string.Empty, + EntityVersion = string.Empty, + Item = string.Empty, + Sequence = 0, + Version = 0 + }; + + Func act = async () => _ = await ExecutableOperation.CreateAsync(op); + await act.Should().ThrowAsync(); + } + + [Theory] + [InlineData(null, "https://test.zumo.com/tables/movies", "https://test.zumo.com/tables/movies/")] + [InlineData(null, "https://test.zumo.com/tables/movies/", "https://test.zumo.com/tables/movies/")] + [InlineData("https://test.zumo.com", "/tables/movies", "https://test.zumo.com/tables/movies/")] + [InlineData("https://test.zumo.com", "/tables/movies/", "https://test.zumo.com/tables/movies/")] + [InlineData("https://test.zumo.com/", "/tables/movies", "https://test.zumo.com/tables/movies/")] + [InlineData("https://test.zumo.com/", "/tables/movies/", "https://test.zumo.com/tables/movies/")] + [InlineData("https://test.zumo.com/tables", "movies", "https://test.zumo.com/movies/")] + [InlineData("https://test.zumo.com/tables", "movies/", "https://test.zumo.com/movies/")] + [InlineData("https://test.zumo.com/tables", "/api/movies", "https://test.zumo.com/api/movies/")] + [InlineData("https://test.zumo.com/tables", "/api/movies/", "https://test.zumo.com/api/movies/")] + public void MakeAbsoluteUri_Works(string ba, string bb, string expected) + { + Uri arg1 = string.IsNullOrEmpty(ba) ? null : new Uri(ba, UriKind.Absolute); + Uri arg2 = bb.StartsWith("http") ? new Uri(bb, UriKind.Absolute) : new Uri(bb, UriKind.Relative); + Uri actual = ExecutableOperation.MakeAbsoluteUri(arg1, arg2); + + actual.ToString().Should().Be(expected); + } + + [Fact] + public void MakeAbsoluteUri_BaseAddressRelative() + { + Uri arg1 = new("tables/movies", UriKind.Relative); + Uri arg2 = new("tables/movies", UriKind.Relative); + + Action act = () => ExecutableOperation.MakeAbsoluteUri(arg1, arg2); + act.Should().Throw(); + } + + [Fact] + public async Task AddOperation_ExecuteAsync() + { + MockDelegatingHandler handler = new(); + HttpClient client = new(handler) { BaseAddress = new Uri("https://test.zumo.net")}; + string itemJson = """{"id":"123"}"""; + DatasyncOperation op = new() + { + Id = Guid.NewGuid().ToString(), + Kind = OperationKind.Add, + State = OperationState.Pending, + EntityType = typeof(ClientMovie).FullName, + ItemId = "123", + EntityVersion = string.Empty, + Item = itemJson, + Sequence = 0, + Version = 0 + }; + + ClientMovie expected = new() { Id = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(expected); + string expectedJson = DatasyncSerializer.Serialize(expected); + handler.AddResponse(HttpStatusCode.Created, expected); + + ExecutableOperation operation = await ExecutableOperation.CreateAsync(op); + ServiceResponse response = await operation.ExecuteAsync(client, new Uri("/tables/movies", UriKind.Relative)); + + HttpRequestMessage request = handler.Requests.SingleOrDefault(); + request.Should().NotBeNull(); + request.Method.Should().Be(HttpMethod.Post); + request.RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/"); + (await request.Content.ReadAsStringAsync()).Should().Be(itemJson); + + response.Should().NotBeNull(); + response.HasContent.Should().BeTrue(); + response.IsConflictStatusCode.Should().BeFalse(); + response.IsSuccessful.Should().BeTrue(); + response.ReasonPhrase.Should().NotBeNullOrEmpty(); + response.StatusCode.Should().Be(201); + string content = new StreamReader(response.ContentStream).ReadToEnd(); + content.Should().Be(expectedJson); + } + + [Fact] + public async Task RemoveOperation_ExecuteAsync() + { + MockDelegatingHandler handler = new(); + HttpClient client = new(handler) { BaseAddress = new Uri("https://test.zumo.net") }; + string itemJson = """{"id":"123"}"""; + DatasyncOperation op = new() + { + Id = Guid.NewGuid().ToString(), + Kind = OperationKind.Delete, + State = OperationState.Pending, + EntityType = typeof(ClientMovie).FullName, + ItemId = "123", + EntityVersion = string.Empty, + Item = itemJson, + Sequence = 0, + Version = 0 + }; + + ClientMovie expected = new() { Id = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(expected); + handler.AddResponse(HttpStatusCode.NoContent); + + ExecutableOperation operation = await ExecutableOperation.CreateAsync(op); + ServiceResponse response = await operation.ExecuteAsync(client, new Uri("/tables/movies", UriKind.Relative)); + + HttpRequestMessage request = handler.Requests.SingleOrDefault(); + request.Should().NotBeNull(); + request.Method.Should().Be(HttpMethod.Delete); + request.RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/123"); + request.Should().NotHaveHeader("If-Match"); + + response.Should().NotBeNull(); + response.HasContent.Should().BeFalse(); + response.IsConflictStatusCode.Should().BeFalse(); + response.IsSuccessful.Should().BeTrue(); + response.ReasonPhrase.Should().NotBeNullOrEmpty(); + response.StatusCode.Should().Be(204); + } + + [Fact] + public async Task RemoveOperation_ExecuteAsync_WithVersion() + { + MockDelegatingHandler handler = new(); + HttpClient client = new(handler) { BaseAddress = new Uri("https://test.zumo.net") }; + string itemJson = """{"id":"123"}"""; + DatasyncOperation op = new() + { + Id = Guid.NewGuid().ToString(), + Kind = OperationKind.Delete, + State = OperationState.Pending, + EntityType = typeof(ClientMovie).FullName, + ItemId = "123", + EntityVersion = "abcdefg", + Item = itemJson, + Sequence = 0, + Version = 0 + }; + + ClientMovie expected = new() { Id = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(expected); + handler.AddResponse(HttpStatusCode.NoContent); + + ExecutableOperation operation = await ExecutableOperation.CreateAsync(op); + ServiceResponse response = await operation.ExecuteAsync(client, new Uri("/tables/movies", UriKind.Relative)); + + HttpRequestMessage request = handler.Requests.SingleOrDefault(); + request.Should().NotBeNull(); + request.Method.Should().Be(HttpMethod.Delete); + request.RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/123"); + request.Should().HaveHeader("If-Match", "\"abcdefg\""); + + response.Should().NotBeNull(); + response.HasContent.Should().BeFalse(); + response.IsConflictStatusCode.Should().BeFalse(); + response.IsSuccessful.Should().BeTrue(); + response.ReasonPhrase.Should().NotBeNullOrEmpty(); + response.StatusCode.Should().Be(204); + } + + [Fact] + public async Task ReplaceOperation_ExecuteAsync() + { + MockDelegatingHandler handler = new(); + HttpClient client = new(handler) { BaseAddress = new Uri("https://test.zumo.net") }; + string itemJson = """{"id":"123"}"""; + DatasyncOperation op = new() + { + Id = Guid.NewGuid().ToString(), + Kind = OperationKind.Replace, + State = OperationState.Pending, + EntityType = typeof(ClientMovie).FullName, + ItemId = "123", + EntityVersion = string.Empty, + Item = itemJson, + Sequence = 0, + Version = 0 + }; + + ClientMovie expected = new() { Id = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(expected); + string expectedJson = DatasyncSerializer.Serialize(expected); + handler.AddResponse(HttpStatusCode.OK, expected); + + ExecutableOperation operation = await ExecutableOperation.CreateAsync(op); + ServiceResponse response = await operation.ExecuteAsync(client, new Uri("/tables/movies", UriKind.Relative)); + + HttpRequestMessage request = handler.Requests.SingleOrDefault(); + request.Should().NotBeNull(); + request.Method.Should().Be(HttpMethod.Put); + request.RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/123"); + request.Should().NotHaveHeader("If-Match"); + (await request.Content.ReadAsStringAsync()).Should().Be(itemJson); + + response.Should().NotBeNull(); + response.HasContent.Should().BeTrue(); + response.IsConflictStatusCode.Should().BeFalse(); + response.IsSuccessful.Should().BeTrue(); + response.ReasonPhrase.Should().NotBeNullOrEmpty(); + response.StatusCode.Should().Be(200); + string content = new StreamReader(response.ContentStream).ReadToEnd(); + content.Should().Be(expectedJson); + } + + [Fact] + public async Task ReplaceOperation_ExecuteAsync_WithVersion() + { + MockDelegatingHandler handler = new(); + HttpClient client = new(handler) { BaseAddress = new Uri("https://test.zumo.net") }; + string itemJson = """{"id":"123"}"""; + DatasyncOperation op = new() + { + Id = Guid.NewGuid().ToString(), + Kind = OperationKind.Replace, + State = OperationState.Pending, + EntityType = typeof(ClientMovie).FullName, + ItemId = "123", + EntityVersion = "abcdefg", + Item = itemJson, + Sequence = 0, + Version = 0 + }; + + ClientMovie expected = new() { Id = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(expected); + string expectedJson = DatasyncSerializer.Serialize(expected); + handler.AddResponse(HttpStatusCode.OK, expected); + + ExecutableOperation operation = await ExecutableOperation.CreateAsync(op); + ServiceResponse response = await operation.ExecuteAsync(client, new Uri("/tables/movies", UriKind.Relative)); + + HttpRequestMessage request = handler.Requests.SingleOrDefault(); + request.Should().NotBeNull(); + request.Method.Should().Be(HttpMethod.Put); + request.RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/123"); + request.Should().HaveHeader("If-Match", "\"abcdefg\""); + (await request.Content.ReadAsStringAsync()).Should().Be(itemJson); + + response.Should().NotBeNull(); + response.HasContent.Should().BeTrue(); + response.IsConflictStatusCode.Should().BeFalse(); + response.IsSuccessful.Should().BeTrue(); + response.ReasonPhrase.Should().NotBeNullOrEmpty(); + response.StatusCode.Should().Be(200); + string content = new StreamReader(response.ContentStream).ReadToEnd(); + content.Should().Be(expectedJson); + } +} diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/BaseTest.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/BaseTest.cs index 8582b34..129768c 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/BaseTest.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/BaseTest.cs @@ -4,6 +4,8 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using System.Net; +using System.Net.Http.Headers; namespace CommunityToolkit.Datasync.Client.Test.Offline.Helpers; @@ -13,7 +15,6 @@ public abstract class BaseTest /// /// Creates a version of the TestDbContext backed by SQLite. /// - /// protected static TestDbContext CreateContext() { SqliteConnection connection = new("Data Source=:memory:"); @@ -26,4 +27,17 @@ protected static TestDbContext CreateContext() context.Database.EnsureCreated(); return context; } + + /// + /// Creates a response message based on code and content. + /// + protected static HttpResponseMessage GetResponse(string content, HttpStatusCode code) + { + HttpResponseMessage response = new(code) + { + Content = new StringContent(content, MediaTypeHeaderValue.Parse("application/json")) + }; + return response; + + } } diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/TestDbContext.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/TestDbContext.cs index a22d365..ee4b5f3 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/TestDbContext.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/TestDbContext.cs @@ -4,8 +4,10 @@ #pragma warning disable IDE0051 // Remove unused private members +using CommunityToolkit.Datasync.Client.Http; using CommunityToolkit.Datasync.Client.Offline; using CommunityToolkit.Datasync.TestCommon.Databases; +using CommunityToolkit.Datasync.TestCommon.Mocks; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -25,6 +27,22 @@ public TestDbContext(DbContextOptions options) : base(options) { } + protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder) + { + HttpClientOptions clientOptions = new() + { + Endpoint = new Uri("https://test.zumo.net"), + HttpPipeline = [ Handler ] + }; + optionsBuilder + .UseHttpClientOptions(clientOptions) + .Entity(c => + { + c.ClientName = "movies"; + c.Endpoint = new Uri("/tables/movies", UriKind.Relative); + }); + } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { if (!optionsBuilder.IsConfigured) @@ -39,6 +57,8 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) internal SqliteConnection Connection { get; set; } + internal MockDelegatingHandler Handler { get; set; } = new(); + public DbSet Movies => Set(); [DoNotSynchronize] diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs index 1d5938d..fa8b0a5 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs @@ -2,11 +2,15 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +#pragma warning disable IDE0305 // Simplify collection initialization + using CommunityToolkit.Datasync.Client.Offline; using CommunityToolkit.Datasync.Client.Serialization; using CommunityToolkit.Datasync.Client.Test.Offline.Helpers; +using CommunityToolkit.Datasync.TestCommon; using CommunityToolkit.Datasync.TestCommon.Databases; -using System.Text.Json; +using System.Net; +using System.Text; using TestData = CommunityToolkit.Datasync.TestCommon.TestData; namespace CommunityToolkit.Datasync.Client.Test.Offline; @@ -23,6 +27,806 @@ public void Default_Ctor_CreateQueueManager() } #endregion + #region GetOfflineOptions + [Fact] + public void GetOfflineOptions_Null() + { + TestDbContext context = CreateContext(); + Action act = () => _ = context.GetOfflineOptions(null); + act.Should().Throw(); + } + + [Fact] + public void GetOfflineOptions_Works() + { + TestDbContext context = CreateContext(); + DatasyncOfflineOptions actual = context.GetOfflineOptions(typeof(ClientMovie)); + + actual.HttpClient.Should().NotBeNull(); + actual.HttpClient.BaseAddress.ToString().Should().Be("https://test.zumo.net/"); + actual.Endpoint.ToString().Should().Be("/tables/movies"); + } + #endregion + + #region PushAsync + [Fact] + public async Task PushAsync_BadPushOptions() + { + TestDbContext context = CreateContext(); + PushOptions options = new() { ParallelOperations = 0 }; + Type[] entityTypes = [typeof(ClientMovie)]; + + Func act = async () => _ = await context.PushAsync(entityTypes, options); + await act.Should().ThrowAsync(); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public async Task PushAsync_NoSynchronizableEntities(int nItems) + { + TestDbContext context = CreateContext(); + PushOptions options = new(); + List allowedTypes = [typeof(Entity1), typeof(Entity2), typeof(Entity4)]; + Type[] entityTypes = allowedTypes.Take(nItems).ToArray(); + + Func act = async () => _ = await context.PushAsync(entityTypes, options); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PushAsync_NoOperations() + { + TestDbContext context = CreateContext(); + PushOptions options = new(); + Type[] entityTypes = [typeof(ClientMovie)]; + + PushOperationResult result = await context.PushAsync(entityTypes, options); + result.CompletedOperations.Should().Be(0); + result.FailedOperations.Count.Should().Be(0); + } + + [Fact] + public async void PushAsync_Addition_Works() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string clientMovieJson = DatasyncSerializer.Serialize(clientMovie); + + context.Movies.Add(clientMovie); + context.SaveChanges(); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + PushOperationResult results = await context.PushAsync(); + results.IsSuccessful.Should().BeTrue(); + results.CompletedOperations.Should().Be(1); + results.FailedOperations.Should().BeEmpty(); + + context.DatasyncOperationsQueue.Should().BeEmpty(); + + ClientMovie actualMovie = context.Movies.SingleOrDefault(x => x.Id == clientMovie.Id); + actualMovie.UpdatedAt!.Should().BeCloseTo((DateTimeOffset)responseMovie.UpdatedAt, TimeSpan.FromMicroseconds(1000)); + actualMovie.Version.Should().Be(responseMovie.Version); + } + + [Fact] + public async Task PushAsync_Addition_HttpError() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + + context.Movies.Add(clientMovie); + context.SaveChanges(); + + context.Handler.AddResponse(HttpStatusCode.InternalServerError); + + PushOperationResult results = await context.PushAsync(); + results.IsSuccessful.Should().BeFalse(); + results.CompletedOperations.Should().Be(0); + results.FailedOperations.Should().HaveCount(1); + ServiceResponse result = results.FailedOperations.First().Value; + result.StatusCode.Should().Be(500); + result.HasContent.Should().BeFalse(); + + context.DatasyncOperationsQueue.Should().HaveCount(1); + } + + [Fact] + public async Task PushAsync_Addition_Conflict() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + + context.Movies.Add(clientMovie); + context.SaveChanges(); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + PushOperationResult results = await context.PushAsync(); + results.IsSuccessful.Should().BeFalse(); + results.CompletedOperations.Should().Be(0); + results.FailedOperations.Should().HaveCount(1); + ServiceResponse result = results.FailedOperations.First().Value; + result.StatusCode.Should().Be(409); + result.HasContent.Should().BeTrue(); + string content = new StreamReader(result.ContentStream).ReadToEnd(); + content.Should().Be(expectedJson); + + context.DatasyncOperationsQueue.Should().HaveCount(1); + } + + [Fact] + public async Task PushAsync_Removal_Works() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + context.Handler.AddResponse(HttpStatusCode.NoContent); + + PushOperationResult results = await context.PushAsync(); + results.IsSuccessful.Should().BeTrue(); + results.CompletedOperations.Should().Be(1); + results.FailedOperations.Should().BeEmpty(); + + context.DatasyncOperationsQueue.Should().BeEmpty(); + context.Movies.Find(clientMovie.Id).Should().BeNull(); + } + + [Fact] + public async Task PushAsync_Removal_HttpError() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + context.Handler.AddResponse(HttpStatusCode.InternalServerError); + + PushOperationResult results = await context.PushAsync(); + results.IsSuccessful.Should().BeFalse(); + results.CompletedOperations.Should().Be(0); + results.FailedOperations.Should().HaveCount(1); + ServiceResponse result = results.FailedOperations.First().Value; + result.StatusCode.Should().Be(500); + result.HasContent.Should().BeFalse(); + + context.DatasyncOperationsQueue.Should().HaveCount(1); + } + + [Fact] + public async Task PushAsync_Removal_Conflict() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + PushOperationResult results = await context.PushAsync(); + results.IsSuccessful.Should().BeFalse(); + results.CompletedOperations.Should().Be(0); + results.FailedOperations.Should().HaveCount(1); + ServiceResponse result = results.FailedOperations.First().Value; + result.StatusCode.Should().Be(409); + result.HasContent.Should().BeTrue(); + string content = new StreamReader(result.ContentStream).ReadToEnd(); + content.Should().Be(expectedJson); + + context.DatasyncOperationsQueue.Should().HaveCount(1); + } + + [Fact] + public async Task PushAsync_Replacement_Works() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Foo"; + context.Update(clientMovie); + context.SaveChanges(); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + PushOperationResult results = await context.PushAsync(); + results.IsSuccessful.Should().BeTrue(); + results.CompletedOperations.Should().Be(1); + results.FailedOperations.Should().BeEmpty(); + + context.DatasyncOperationsQueue.Should().BeEmpty(); + } + + [Fact] + public async Task PushAsync_Replacement_HttpError() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Foo"; + context.Update(clientMovie); + context.SaveChanges(); + + context.Handler.AddResponse(HttpStatusCode.InternalServerError); + + PushOperationResult results = await context.PushAsync(); + results.IsSuccessful.Should().BeFalse(); + results.CompletedOperations.Should().Be(0); + results.FailedOperations.Should().HaveCount(1); + ServiceResponse result = results.FailedOperations.First().Value; + result.StatusCode.Should().Be(500); + result.HasContent.Should().BeFalse(); + + context.DatasyncOperationsQueue.Should().HaveCount(1); + } + + [Fact] + public async Task PushAsync_Replacment_Conflict() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Foo"; + context.Update(clientMovie); + context.SaveChanges(); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + PushOperationResult results = await context.PushAsync(); + results.IsSuccessful.Should().BeFalse(); + results.CompletedOperations.Should().Be(0); + results.FailedOperations.Should().HaveCount(1); + ServiceResponse result = results.FailedOperations.First().Value; + result.StatusCode.Should().Be(409); + result.HasContent.Should().BeTrue(); + string content = new StreamReader(result.ContentStream).ReadToEnd(); + content.Should().Be(expectedJson); + + context.DatasyncOperationsQueue.Should().HaveCount(1); + } + #endregion + + #region PushOperationAsync + [Fact] + public async void PushOperationAsync_Addition_Works() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string clientMovieJson = DatasyncSerializer.Serialize(clientMovie); + + context.Movies.Add(clientMovie); + context.SaveChanges(); + + DatasyncOperation op = context.DatasyncOperationsQueue.Single(x => x.ItemId == clientMovie.Id); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + ServiceResponse result = await context.PushOperationAsync(op); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + result.Should().BeNull(); + + HttpRequestMessage request = context.Handler.Requests.SingleOrDefault(); + request.Should().NotBeNull(); + request.Method.Should().Be(HttpMethod.Post); + request.RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/"); + (await request.Content.ReadAsStringAsync()).Should().Be(clientMovieJson); + + ClientMovie actualMovie = context.Movies.SingleOrDefault(x => x.Id == clientMovie.Id); + actualMovie.UpdatedAt!.Should().BeCloseTo((DateTimeOffset)responseMovie.UpdatedAt, TimeSpan.FromMicroseconds(1000)); + actualMovie.Version.Should().Be(responseMovie.Version); + + DatasyncOperation afterOp = context.DatasyncOperationsQueue.SingleOrDefault(x => x.ItemId == clientMovie.Id); + afterOp.Should().BeNull(); + } + + [Fact] + public async Task PushOperationAsync_Addition_HttpError() + { + DateTimeOffset StartTime = DateTimeOffset.UtcNow; + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string clientMovieJson = DatasyncSerializer.Serialize(clientMovie); + + context.Movies.Add(clientMovie); + context.SaveChanges(); + + DatasyncOperation op = context.DatasyncOperationsQueue.Single(x => x.ItemId == clientMovie.Id); + + context.Handler.AddResponse(HttpStatusCode.InternalServerError); + + ServiceResponse result = await context.PushOperationAsync(op); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + result.Should().NotBeNull(); + result.StatusCode.Should().Be(500); + result.HasContent.Should().BeFalse(); + + DatasyncOperation afterOp = context.DatasyncOperationsQueue.SingleOrDefault(x => x.ItemId == clientMovie.Id); + afterOp.Should().NotBeNull(); + afterOp.EntityType.Should().Be(op.EntityType); + afterOp.EntityVersion.Should().Be(op.EntityVersion); + afterOp.HttpStatusCode.Should().Be(500); + afterOp.Id.Should().Be(op.Id); + afterOp.LastAttempt.Should().BeAfter(StartTime).And.BeBefore(DateTimeOffset.UtcNow); + afterOp.State.Should().Be(OperationState.Failed); + } + + [Fact] + public async Task PushOperationAsync_Addition_Conflict() + { + DateTimeOffset StartTime = DateTimeOffset.UtcNow; + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string clientMovieJson = DatasyncSerializer.Serialize(clientMovie); + + context.Movies.Add(clientMovie); + context.SaveChanges(); + + DatasyncOperation op = context.DatasyncOperationsQueue.Single(x => x.ItemId == clientMovie.Id); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + ServiceResponse result = await context.PushOperationAsync(op); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + result.Should().NotBeNull(); + result.StatusCode.Should().Be(409); + result.HasContent.Should().BeTrue(); + string content = new StreamReader(result.ContentStream).ReadToEnd(); + content.Should().Be(expectedJson); + + HttpRequestMessage request = context.Handler.Requests.SingleOrDefault(); + request.Should().NotBeNull(); + request.Method.Should().Be(HttpMethod.Post); + request.RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/"); + (await request.Content.ReadAsStringAsync()).Should().Be(clientMovieJson); + + ClientMovie actualMovie = context.Movies.SingleOrDefault(x => x.Id == clientMovie.Id); + actualMovie.Should().BeEquivalentTo(clientMovie); + + DatasyncOperation afterOp = context.DatasyncOperationsQueue.SingleOrDefault(x => x.ItemId == clientMovie.Id); + afterOp.Should().NotBeNull(); + afterOp.EntityType.Should().Be(op.EntityType); + afterOp.EntityVersion.Should().Be(op.EntityVersion); + afterOp.HttpStatusCode.Should().Be(409); + afterOp.Id.Should().Be(op.Id); + afterOp.LastAttempt.Should().BeAfter(StartTime).And.BeBefore(DateTimeOffset.UtcNow); + afterOp.State.Should().Be(OperationState.Failed); + } + + [Fact] + public async Task PushOperationAsync_Removal_Works() + { + DateTimeOffset StartTime = DateTimeOffset.UtcNow; + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string clientMovieJson = DatasyncSerializer.Serialize(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + DatasyncOperation op = context.DatasyncOperationsQueue.Single(x => x.ItemId == clientMovie.Id); + + context.Handler.AddResponse(HttpStatusCode.NoContent); + + ServiceResponse result = await context.PushOperationAsync(op); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + result.Should().BeNull(); + + HttpRequestMessage request = context.Handler.Requests.SingleOrDefault(); + request.Should().NotBeNull(); + request.Method.Should().Be(HttpMethod.Delete); + request.RequestUri.ToString().Should().Be($"https://test.zumo.net/tables/movies/{clientMovie.Id}"); + request.Should().NotHaveHeader("If-Match"); + + DatasyncOperation afterOp = context.DatasyncOperationsQueue.SingleOrDefault(x => x.ItemId == clientMovie.Id); + afterOp.Should().BeNull(); + } + + [Fact] + public async Task PushOperationAsync_Removal_Works_WithVersion() + { + DateTimeOffset StartTime = DateTimeOffset.UtcNow; + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N"), Version = "1234" }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string clientMovieJson = DatasyncSerializer.Serialize(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + DatasyncOperation op = context.DatasyncOperationsQueue.Single(x => x.ItemId == clientMovie.Id); + + context.Handler.AddResponse(HttpStatusCode.NoContent); + + ServiceResponse result = await context.PushOperationAsync(op); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + result.Should().BeNull(); + + HttpRequestMessage request = context.Handler.Requests.SingleOrDefault(); + request.Should().NotBeNull(); + request.Method.Should().Be(HttpMethod.Delete); + request.RequestUri.ToString().Should().Be($"https://test.zumo.net/tables/movies/{clientMovie.Id}"); + request.Should().HaveHeader("If-Match", "\"1234\""); + + DatasyncOperation afterOp = context.DatasyncOperationsQueue.SingleOrDefault(x => x.ItemId == clientMovie.Id); + afterOp.Should().BeNull(); + } + + [Fact] + public async Task PushOperationAsync_Removal_HttpError() + { + DateTimeOffset StartTime = DateTimeOffset.UtcNow; + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string clientMovieJson = DatasyncSerializer.Serialize(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + DatasyncOperation op = context.DatasyncOperationsQueue.Single(x => x.ItemId == clientMovie.Id); + + context.Handler.AddResponse(HttpStatusCode.InternalServerError); + + ServiceResponse result = await context.PushOperationAsync(op); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + result.Should().NotBeNull(); + result.StatusCode.Should().Be(500); + result.HasContent.Should().BeFalse(); + + HttpRequestMessage request = context.Handler.Requests.SingleOrDefault(); + request.Should().NotBeNull(); + request.Method.Should().Be(HttpMethod.Delete); + request.RequestUri.ToString().Should().Be($"https://test.zumo.net/tables/movies/{clientMovie.Id}"); + request.Should().NotHaveHeader("If-Match"); + + DatasyncOperation afterOp = context.DatasyncOperationsQueue.SingleOrDefault(x => x.ItemId == clientMovie.Id); + afterOp.Should().NotBeNull(); + afterOp.EntityType.Should().Be(op.EntityType); + afterOp.EntityVersion.Should().Be(op.EntityVersion); + afterOp.HttpStatusCode.Should().Be(500); + afterOp.Id.Should().Be(op.Id); + afterOp.LastAttempt.Should().BeAfter(StartTime).And.BeBefore(DateTimeOffset.UtcNow); + afterOp.State.Should().Be(OperationState.Failed); + } + + [Fact] + public async Task PushOperationAsync_Removal_Conflict() + { + DateTimeOffset StartTime = DateTimeOffset.UtcNow; + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string clientMovieJson = DatasyncSerializer.Serialize(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + DatasyncOperation op = context.DatasyncOperationsQueue.Single(x => x.ItemId == clientMovie.Id); + ServiceResponse result = await context.PushOperationAsync(op); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + result.Should().NotBeNull(); + result.StatusCode.Should().Be(409); + result.HasContent.Should().BeTrue(); + string content = new StreamReader(result.ContentStream).ReadToEnd(); + content.Should().Be(expectedJson); + + HttpRequestMessage request = context.Handler.Requests.SingleOrDefault(); + request.Should().NotBeNull(); + request.Method.Should().Be(HttpMethod.Delete); + request.RequestUri.ToString().Should().Be($"https://test.zumo.net/tables/movies/{clientMovie.Id}"); + request.Should().NotHaveHeader("If-Match"); + + DatasyncOperation afterOp = context.DatasyncOperationsQueue.SingleOrDefault(x => x.ItemId == clientMovie.Id); + afterOp.Should().NotBeNull(); + afterOp.EntityType.Should().Be(op.EntityType); + afterOp.EntityVersion.Should().Be(op.EntityVersion); + afterOp.HttpStatusCode.Should().Be(409); + afterOp.Id.Should().Be(op.Id); + afterOp.LastAttempt.Should().BeAfter(StartTime).And.BeBefore(DateTimeOffset.UtcNow); + afterOp.State.Should().Be(OperationState.Failed); + } + + [Fact] + public async Task PushOperationAsync_Replacement_Works() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Foo"; + context.Update(clientMovie); + context.SaveChanges(); + string clientMovieJson = DatasyncSerializer.Serialize(clientMovie); + + DatasyncOperation op = context.DatasyncOperationsQueue.Single(x => x.ItemId == clientMovie.Id); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + ServiceResponse result = await context.PushOperationAsync(op); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + result.Should().BeNull(); + + HttpRequestMessage request = context.Handler.Requests.SingleOrDefault(); + request.Should().NotBeNull(); + request.Method.Should().Be(HttpMethod.Put); + request.RequestUri.ToString().Should().Be($"https://test.zumo.net/tables/movies/{clientMovie.Id}"); + request.Should().NotHaveHeader("If-Match"); + (await request.Content.ReadAsStringAsync()).Should().Be(clientMovieJson); + + ClientMovie actualMovie = context.Movies.SingleOrDefault(x => x.Id == clientMovie.Id); + actualMovie.UpdatedAt!.Should().BeCloseTo((DateTimeOffset)responseMovie.UpdatedAt, TimeSpan.FromMicroseconds(1000)); + actualMovie.Version.Should().Be(responseMovie.Version); + + DatasyncOperation afterOp = context.DatasyncOperationsQueue.SingleOrDefault(x => x.ItemId == clientMovie.Id); + afterOp.Should().BeNull(); + } + + [Fact] + public async Task PushOperationAsync_Replacement_Works_WithVersion() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N"), Version="1234" }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Foo"; + context.Update(clientMovie); + context.SaveChanges(); + string clientMovieJson = DatasyncSerializer.Serialize(clientMovie); + + DatasyncOperation op = context.DatasyncOperationsQueue.Single(x => x.ItemId == clientMovie.Id); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + ServiceResponse result = await context.PushOperationAsync(op); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + result.Should().BeNull(); + + HttpRequestMessage request = context.Handler.Requests.SingleOrDefault(); + request.Should().NotBeNull(); + request.Method.Should().Be(HttpMethod.Put); + request.RequestUri.ToString().Should().Be($"https://test.zumo.net/tables/movies/{clientMovie.Id}"); + request.Should().HaveHeader("If-Match", "\"1234\""); + (await request.Content.ReadAsStringAsync()).Should().Be(clientMovieJson); + + ClientMovie actualMovie = context.Movies.SingleOrDefault(x => x.Id == clientMovie.Id); + actualMovie.UpdatedAt!.Should().BeCloseTo((DateTimeOffset)responseMovie.UpdatedAt, TimeSpan.FromMicroseconds(1000)); + actualMovie.Version.Should().Be(responseMovie.Version); + + DatasyncOperation afterOp = context.DatasyncOperationsQueue.SingleOrDefault(x => x.ItemId == clientMovie.Id); + afterOp.Should().BeNull(); + } + + [Fact] + public async Task PushOperationAsync_Replacement_HttpError() + { + DateTimeOffset StartTime = DateTimeOffset.UtcNow; + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Foo"; + context.Update(clientMovie); + context.SaveChanges(); + string clientMovieJson = DatasyncSerializer.Serialize(clientMovie); + + DatasyncOperation op = context.DatasyncOperationsQueue.Single(x => x.ItemId == clientMovie.Id); + + context.Handler.AddResponse(HttpStatusCode.InternalServerError); + + ServiceResponse result = await context.PushOperationAsync(op); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + result.Should().NotBeNull(); + result.StatusCode.Should().Be(500); + result.HasContent.Should().BeFalse(); + + DatasyncOperation afterOp = context.DatasyncOperationsQueue.SingleOrDefault(x => x.ItemId == clientMovie.Id); + afterOp.Should().NotBeNull(); + afterOp.EntityType.Should().Be(op.EntityType); + afterOp.EntityVersion.Should().Be(op.EntityVersion); + afterOp.HttpStatusCode.Should().Be(500); + afterOp.Id.Should().Be(op.Id); + afterOp.LastAttempt.Should().BeAfter(StartTime).And.BeBefore(DateTimeOffset.UtcNow); + afterOp.State.Should().Be(OperationState.Failed); + } + + [Fact] + public async Task PushOperationAsync_Replacment_Conflict() + { + DateTimeOffset StartTime = DateTimeOffset.UtcNow; + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Foo"; + context.Update(clientMovie); + context.SaveChanges(); + string clientMovieJson = DatasyncSerializer.Serialize(clientMovie); + + DatasyncOperation op = context.DatasyncOperationsQueue.Single(x => x.ItemId == clientMovie.Id); + + ClientMovie responseMovie = new() { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; + TestData.Movies.BlackPanther.CopyTo(responseMovie); + string expectedJson = DatasyncSerializer.Serialize(responseMovie); + context.Handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }); + + ServiceResponse result = await context.PushOperationAsync(op); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + result.Should().NotBeNull(); + result.StatusCode.Should().Be(409); + result.HasContent.Should().BeTrue(); + string content = new StreamReader(result.ContentStream).ReadToEnd(); + content.Should().Be(expectedJson); + + HttpRequestMessage request = context.Handler.Requests.SingleOrDefault(); + request.Should().NotBeNull(); + request.Method.Should().Be(HttpMethod.Put); + request.RequestUri.ToString().Should().Be($"https://test.zumo.net/tables/movies/{clientMovie.Id}"); + (await request.Content.ReadAsStringAsync()).Should().Be(clientMovieJson); + + ClientMovie actualMovie = context.Movies.SingleOrDefault(x => x.Id == clientMovie.Id); + actualMovie.Should().BeEquivalentTo(clientMovie); + + DatasyncOperation afterOp = context.DatasyncOperationsQueue.SingleOrDefault(x => x.ItemId == clientMovie.Id); + afterOp.Should().NotBeNull(); + afterOp.EntityType.Should().Be(op.EntityType); + afterOp.EntityVersion.Should().Be(op.EntityVersion); + afterOp.HttpStatusCode.Should().Be(409); + afterOp.Id.Should().Be(op.Id); + afterOp.LastAttempt.Should().BeAfter(StartTime).And.BeBefore(DateTimeOffset.UtcNow); + afterOp.State.Should().Be(OperationState.Failed); + } + + [Fact] + public async Task PushOperationAsync_InvalidEntityType_Throws() + { + TestDbContext context = CreateContext(); + DatasyncOperation op = new() + { + Id = Guid.NewGuid().ToString(), + Kind = OperationKind.Replace, + State = OperationState.Pending, + EntityType = typeof(Entity1).FullName, // any entity that is not synchronizable + ItemId = "123", + EntityVersion = "abcdefg", + Item = string.Empty, + Sequence = 0, + Version = 0 + }; + + Func act = () => context.PushOperationAsync(op); + await act.Should().ThrowAsync(); + } + #endregion + + #region ReplaceDatabaseValues + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + public void ReplaceDatabaseValues_EdgeCases(bool o1IsNull, bool o2IsNull) + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + + object o1 = o1IsNull ? null : clientMovie; + object o2 = o2IsNull ? null : clientMovie; + + Action act1 = () => context.ReplaceDatabaseValue(o1, o2); + act1.Should().Throw(); + } + #endregion + #region SaveChanges [Fact] public void SaveChanges_Addition_AddsToQueue() @@ -30,7 +834,7 @@ public void SaveChanges_Addition_AddsToQueue() TestDbContext context = CreateContext(); ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; TestData.Movies.BlackPanther.CopyTo(clientMovie); - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Add(clientMovie); context.SaveChanges(); @@ -54,11 +858,11 @@ public void SaveChanges_TwoAdds_AddsToQueue() TestDbContext context = CreateContext(); ClientMovie firstMovie = new() { Id = Guid.NewGuid().ToString("N") }; TestData.Movies.BlackPanther.CopyTo(firstMovie); - string firstMovieJson = JsonSerializer.Serialize(firstMovie, DatasyncSerializer.JsonSerializerOptions); + string firstMovieJson = DatasyncSerializer.Serialize(firstMovie); ClientMovie secondMovie = new() { Id = Guid.NewGuid().ToString("N") }; TestData.Movies.BlackPanther.CopyTo(secondMovie); - string secondMovieJson = JsonSerializer.Serialize(secondMovie, DatasyncSerializer.JsonSerializerOptions); + string secondMovieJson = DatasyncSerializer.Serialize(secondMovie); context.Movies.Add(firstMovie); context.Movies.Add(secondMovie); @@ -133,7 +937,7 @@ public void SaveChanges_AddThenReplace_AddsToQueue() context.SaveChanges(); clientMovie.Title = "Foo"; - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Update(clientMovie); context.SaveChanges(); @@ -156,7 +960,7 @@ public void SaveChanges_Deletion_AddsToQueue() TestDbContext context = CreateContext(); ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; TestData.Movies.BlackPanther.CopyTo(clientMovie); - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Add(clientMovie); context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); @@ -182,7 +986,7 @@ public void SaveChanges_DeleteThenAdd_AddsToQueue() TestDbContext context = CreateContext(); ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; TestData.Movies.BlackPanther.CopyTo(clientMovie); - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Add(clientMovie); context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); @@ -211,7 +1015,7 @@ public void SaveChanges_DeleteThenDelete_Throws() TestDbContext context = CreateContext(); ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; TestData.Movies.BlackPanther.CopyTo(clientMovie); - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Add(clientMovie); DatasyncOperation badOperation = new() @@ -220,6 +1024,7 @@ public void SaveChanges_DeleteThenDelete_Throws() Id = Guid.NewGuid().ToString("N"), Item = serializedEntity, ItemId = clientMovie.Id, + EntityVersion = string.Empty, Kind = OperationKind.Delete, State = OperationState.Pending, Sequence = 1, @@ -249,7 +1054,7 @@ public void SaveChanges_Replacement_AddsToQueue() context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); clientMovie.Title = "Replaced Title"; - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Update(clientMovie); context.SaveChanges(); @@ -276,7 +1081,7 @@ public void SaveChanges_ReplaceThenDelete_AddsToQueue() context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); clientMovie.Title = "Replaced Title"; - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Update(clientMovie); context.SaveChanges(); @@ -310,7 +1115,7 @@ public void SaveChanges_ReplaceThenReplace_AddsToQueue() context.SaveChanges(); clientMovie.Title = "Foo"; - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Update(clientMovie); context.SaveChanges(); @@ -335,7 +1140,7 @@ public async Task SaveChangesAsync_Addition_AddsToQueue() TestDbContext context = CreateContext(); ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; TestData.Movies.BlackPanther.CopyTo(clientMovie); - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Add(clientMovie); await context.SaveChangesAsync(); @@ -359,11 +1164,11 @@ public async Task SaveChangesAsync_TwoAdds_AddsToQueue() TestDbContext context = CreateContext(); ClientMovie firstMovie = new() { Id = Guid.NewGuid().ToString("N") }; TestData.Movies.BlackPanther.CopyTo(firstMovie); - string firstMovieJson = JsonSerializer.Serialize(firstMovie, DatasyncSerializer.JsonSerializerOptions); + string firstMovieJson = DatasyncSerializer.Serialize(firstMovie); ClientMovie secondMovie = new() { Id = Guid.NewGuid().ToString("N") }; TestData.Movies.BlackPanther.CopyTo(secondMovie); - string secondMovieJson = JsonSerializer.Serialize(secondMovie, DatasyncSerializer.JsonSerializerOptions); + string secondMovieJson = DatasyncSerializer.Serialize(secondMovie); context.Movies.Add(firstMovie); context.Movies.Add(secondMovie); @@ -438,7 +1243,7 @@ public async Task SaveChangesAsync_AddThenReplace_AddsToQueue() await context.SaveChangesAsync(); clientMovie.Title = "Foo"; - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Update(clientMovie); await context.SaveChangesAsync(); @@ -461,7 +1266,7 @@ public async Task SaveChangesAsync_Deletion_AddsToQueue() TestDbContext context = CreateContext(); ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; TestData.Movies.BlackPanther.CopyTo(clientMovie); - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Add(clientMovie); await context.SaveChangesAsync(acceptAllChangesOnSuccess: true, addToQueue: false); @@ -487,7 +1292,7 @@ public async Task SaveChangesAsync_DeleteThenAdd_AddsToQueue() TestDbContext context = CreateContext(); ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; TestData.Movies.BlackPanther.CopyTo(clientMovie); - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Add(clientMovie); await context.SaveChangesAsync(acceptAllChangesOnSuccess: true, addToQueue: false); @@ -516,7 +1321,7 @@ public async Task SaveChangesAsync_DeleteThenDelete_Throws() TestDbContext context = CreateContext(); ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; TestData.Movies.BlackPanther.CopyTo(clientMovie); - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Add(clientMovie); DatasyncOperation badOperation = new() @@ -525,6 +1330,7 @@ public async Task SaveChangesAsync_DeleteThenDelete_Throws() Id = Guid.NewGuid().ToString("N"), Item = serializedEntity, ItemId = clientMovie.Id, + EntityVersion = string.Empty, Kind = OperationKind.Delete, State = OperationState.Pending, Sequence = 1, @@ -554,7 +1360,7 @@ public async Task SaveChangesAsync_Replacement_AddsToQueue() await context.SaveChangesAsync(acceptAllChangesOnSuccess: true, addToQueue: false); clientMovie.Title = "Replaced Title"; - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Update(clientMovie); await context.SaveChangesAsync(); @@ -581,7 +1387,7 @@ public async Task SaveChangesAsync_ReplaceThenDelete_AddsToQueue() await context.SaveChangesAsync(acceptAllChangesOnSuccess: true, addToQueue: false); clientMovie.Title = "Replaced Title"; - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Update(clientMovie); await context.SaveChangesAsync(); @@ -615,7 +1421,7 @@ public async Task SaveChangesAsync_ReplaceThenReplace_AddsToQueue() await context.SaveChangesAsync(); clientMovie.Title = "Foo"; - string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + string serializedEntity = DatasyncSerializer.Serialize(clientMovie); context.Movies.Update(clientMovie); await context.SaveChangesAsync(); diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs index b3bf371..a3901db 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs @@ -24,13 +24,69 @@ public async Task GetExistingOperationAsync_InvalidId_Throws() } #endregion - #region InitializeDatasyncEntityMap + #region GetSynchronizableEntityTypes + [Fact] + public void GetSynchronizableEntityTypes_NoArg() + { + TestDbContext context = CreateContext(); + OperationsQueueManager sut = context.QueueManager; + List expectedTypes = [typeof(ClientMovie), typeof(Entity3)]; + + sut.GetSynchronizableEntityTypes().Should().BeEquivalentTo(expectedTypes); + } + + [Fact] + public void GetSynchronizableEntityTypes_Empty() + { + TestDbContext context = CreateContext(); + OperationsQueueManager sut = context.QueueManager; + List allowedTypes = []; + List expectedTypes = []; + + sut.GetSynchronizableEntityTypes(allowedTypes).Should().BeEquivalentTo(expectedTypes); + } + + [Fact] + public void GetSynchronizableEntityTypes_None() + { + TestDbContext context = CreateContext(); + OperationsQueueManager sut = context.QueueManager; + List allowedTypes = [typeof(Entity1), typeof(Entity4)]; + List expectedTypes = []; + + sut.GetSynchronizableEntityTypes(allowedTypes).Should().BeEquivalentTo(expectedTypes); + } + + [Fact] + public void GetSynchronizableEntityTypes_Some() + { + TestDbContext context = CreateContext(); + OperationsQueueManager sut = context.QueueManager; + List allowedTypes = [typeof(Entity1), typeof(ClientMovie), typeof(Entity4)]; + List expectedTypes = [typeof(ClientMovie)]; + + sut.GetSynchronizableEntityTypes(allowedTypes).Should().BeEquivalentTo(expectedTypes); + } + + [Fact] + public void GetSynchronizableEntityTypes_All() + { + TestDbContext context = CreateContext(); + OperationsQueueManager sut = context.QueueManager; + List allowedTypes = [typeof(Entity3), typeof(ClientMovie)]; + List expectedTypes = [typeof(ClientMovie), typeof(Entity3)]; + + sut.GetSynchronizableEntityTypes(allowedTypes).Should().BeEquivalentTo(expectedTypes); + } + #endregion + + #region InitializeEntityMap [Fact] public void InitializeDatasyncEntityMap_Works() { TestDbContext context = CreateContext(); OperationsQueueManager sut = context.QueueManager; - sut.InitializeDatasyncEntityMap(); + sut.InitializeEntityMap(); Dictionary expected = new() { @@ -38,7 +94,7 @@ public void InitializeDatasyncEntityMap_Works() { typeof(Entity3).FullName, typeof(Entity3) } }; - sut.DatasyncEntityMap.Should().NotBeNullOrEmpty().And.BeEquivalentTo(expected); + sut.EntityMap.Should().NotBeNullOrEmpty().And.BeEquivalentTo(expected); } #endregion diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Serialization/DatasyncSerializer_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Serialization/DatasyncSerializer_Tests.cs new file mode 100644 index 0000000..f6d9b97 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Serialization/DatasyncSerializer_Tests.cs @@ -0,0 +1,34 @@ +// 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.Serialization; +using CommunityToolkit.Datasync.TestCommon.Databases; + +using TestData = CommunityToolkit.Datasync.TestCommon.TestData; + +namespace CommunityToolkit.Datasync.Client.Test.Serialization; + +[ExcludeFromCodeCoverage] +public class DatasyncSerializer_Tests +{ + [Fact] + public void Serializer_Tests() + { + ClientMovie movie = new() + { + Id = "54E200F5-A0C9-4C77-86F8-1A73168D1A6F", + UpdatedAt = DateTimeOffset.Parse("2024-08-16T13:53:20.123Z"), + Version = "1234" + }; + TestData.Movies.BlackPanther.CopyTo(movie); + + string expected = """{"bestPictureWinner":true,"duration":134,"rating":"PG13","releaseDate":"2018-02-16","title":"Black Panther","year":2018,"id":"54E200F5-A0C9-4C77-86F8-1A73168D1A6F","updatedAt":"2024-08-16T13:53:20.123Z","version":"1234"}"""; + + string actual = DatasyncSerializer.Serialize(movie); + actual.Should().Be(expected); + + actual = DatasyncSerializer.Serialize(movie, typeof(ClientMovie)); + actual.Should().Be(expected); + } +} diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs index 0e60332..341085b 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs @@ -104,9 +104,7 @@ private DatasyncServiceClient GetMockClient() where T : class private static HttpResponseMessage GetSuccessfulResponse(ClientKitchenSink entity, HttpStatusCode code = HttpStatusCode.OK) { - JsonSerializerOptions serializerOptions = DatasyncSerializer.JsonSerializerOptions; - string json = JsonSerializer.Serialize(entity, serializerOptions); - + string json = DatasyncSerializer.Serialize(entity); HttpResponseMessage response = new(code) { Content = new StringContent(json, Encoding.UTF8, "application/json") diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Threading/AsyncLockDictionary_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Threading/AsyncLockDictionary_Tests.cs new file mode 100644 index 0000000..15faa6f --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Threading/AsyncLockDictionary_Tests.cs @@ -0,0 +1,47 @@ +// 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.Threading; + +namespace CommunityToolkit.Datasync.Client.Test.Threading; + +[ExcludeFromCodeCoverage] +public class AsyncLockDictionary_Tests +{ + [Fact] + public void AcquireLock_Works() + { + AsyncLockDictionary sut = new(); + + sut.IsLocked("key1").Should().BeFalse(); + sut.IsLocked("key2").Should().BeFalse(); + + using (sut.AcquireLock("key1")) + { + sut.IsLocked("key1").Should().BeTrue(); + sut.IsLocked("key2").Should().BeFalse(); + } + + sut.IsLocked("key1").Should().BeFalse(); + sut.IsLocked("key2").Should().BeFalse(); + } + + [Fact] + public async Task AcquireLockAsync_Works() + { + AsyncLockDictionary sut = new(); + + sut.IsLocked("key1").Should().BeFalse(); + sut.IsLocked("key2").Should().BeFalse(); + + using (await sut.AcquireLockAsync("key1")) + { + sut.IsLocked("key1").Should().BeTrue(); + sut.IsLocked("key2").Should().BeFalse(); + } + + sut.IsLocked("key1").Should().BeFalse(); + sut.IsLocked("key2").Should().BeFalse(); + } +} diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Threading/DisposableLock_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Threading/DisposableLock_Tests.cs new file mode 100644 index 0000000..220d844 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Threading/DisposableLock_Tests.cs @@ -0,0 +1,39 @@ +// 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.Threading; + +namespace CommunityToolkit.Datasync.Client.Test.Threading; + +[ExcludeFromCodeCoverage] +public class DisposableLock_Tests +{ + [Fact] + public void AcquireLock_Works() + { + DisposableLock sut = new(); + + sut.IsLocked().Should().BeFalse(); + using (sut.AcquireLock()) + { + sut.IsLocked().Should().BeTrue(); + } + + sut.IsLocked().Should().BeFalse(); + } + + [Fact] + public async Task AcquireLockAsync_Works() + { + DisposableLock sut = new(); + + sut.IsLocked().Should().BeFalse(); + using (await sut.AcquireLockAsync()) + { + sut.IsLocked().Should().BeTrue(); + } + + sut.IsLocked().Should().BeFalse(); + } +} diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Threading/DisposeAction_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Threading/DisposeAction_Tests.cs new file mode 100644 index 0000000..d668036 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Threading/DisposeAction_Tests.cs @@ -0,0 +1,37 @@ +// 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.Threading; + +namespace CommunityToolkit.Datasync.Client.Test.Threading; + +[ExcludeFromCodeCoverage] +public class DisposeAction_Tests +{ + [Fact] + public void DisposeAction_CallsAction_OnDispose() + { + bool isCalled = false; + using (DisposeAction sut = new(() => isCalled = true)) + { + Assert.False(isCalled); + } + + Assert.True(isCalled); + } + + [Fact] + public void DisposeAction_CanDisposeTwice() + { + int isCalled = 0; + using (DisposeAction sut = new(() => isCalled++)) + { + Assert.Equal(0, isCalled); + sut.Dispose(); + Assert.Equal(1, isCalled); + } + + Assert.Equal(1, isCalled); + } +} diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Threading/QueueHandler_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Threading/QueueHandler_Tests.cs new file mode 100644 index 0000000..75d070c --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Threading/QueueHandler_Tests.cs @@ -0,0 +1,102 @@ +// 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.Threading; +using System.Collections.Concurrent; + +namespace CommunityToolkit.Datasync.Client.Test.Threading; + +[ExcludeFromCodeCoverage] +public class QueueHandler_Tests +{ + [Theory(Timeout = 30000)] + [InlineData(1)] + [InlineData(2)] + [InlineData(4)] + [InlineData(8)] + public async Task QueueHandler_WithThreads_Enqueue(int nThreads) + { + ConcurrentQueue accId = new(); + ConcurrentQueue accTh = new(); + QueueHandler sut = new(nThreads, (el) => + { + accId.Enqueue(el); + accTh.Enqueue(Environment.CurrentManagedThreadId); + Thread.Sleep(1000); + return Task.CompletedTask; + }); + DateTimeOffset startTime = DateTimeOffset.Now; + + sut.Count.Should().Be(0); + + // Add in some elements + int nElements = nThreads * 5; + for (int i = 0; i < nElements; i++) + { + sut.Enqueue($"el-{i}"); + } + + sut.Count.Should().NotBe(0); + + // Now wait for completion + await sut.WhenComplete(); + DateTimeOffset endTime = DateTimeOffset.Now; + + // Check everything ran. + accId.Should().HaveCount(nElements); + accTh.Should().HaveCount(nElements); + accTh.AsEnumerable().Distinct().Should().HaveCount(nThreads); + (endTime - startTime).TotalSeconds.Should().BeLessThanOrEqualTo((nElements / nThreads) + 2); + } + + [Theory(Timeout = 30000)] + [InlineData(1)] + [InlineData(2)] + [InlineData(4)] + [InlineData(8)] + public async Task QueueHandler_WithThreads_EnqueueRange(int nThreads) + { + ConcurrentQueue accId = new(); + ConcurrentQueue accTh = new(); + QueueHandler sut = new(nThreads, (el) => + { + accId.Enqueue(el); + accTh.Enqueue(Environment.CurrentManagedThreadId); + Thread.Sleep(1000); + return Task.CompletedTask; + }); + DateTimeOffset startTime = DateTimeOffset.Now; + sut.Count.Should().Be(0); + + // Add in some elements + int nElements = nThreads * 5; + List elements = Enumerable.Range(0, nElements).Select(x => $"el-{x}").ToList(); + sut.EnqueueRange(elements); + sut.Count.Should().NotBe(0); + + // Now wait for completion + await sut.WhenComplete(); + DateTimeOffset endTime = DateTimeOffset.Now; + + // Check everything ran. + accId.Should().HaveCount(nElements); + accTh.Should().HaveCount(nElements); + accTh.AsEnumerable().Distinct().Should().HaveCount(nThreads); + (endTime - startTime).TotalSeconds.Should().BeLessThanOrEqualTo((nElements / nThreads) + 2); + } + + [Theory, CombinatorialData] + public void QueueHandler_Throws_OutOfRangeMaxThreads([CombinatorialValues(-1, 0, 9, 10)] int threads) + { + Action act = () => _ = new QueueHandler(threads, _ => Task.CompletedTask); + act.Should().Throw(); + } + + [Fact] + public void QueueHandler_Throws_NullJobRunner() + { + Action act = () => _ = new QueueHandler(1, null); + act.Should().Throw(); + } +} diff --git a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/GlobalSuppressions.cs b/tests/CommunityToolkit.Datasync.Server.NSwag.Test/GlobalSuppressions.cs deleted file mode 100644 index 12b185b..0000000 --- a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/GlobalSuppressions.cs +++ /dev/null @@ -1,7 +0,0 @@ -// This file is used by Code Analysis to maintain SuppressMessage -// attributes that are applied to this project. -// Project-level suppressions either have no target or are given -// a specific target and scoped to a namespace, type, member, etc. - - -[assembly: SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "", Scope = "member", Target = "~M:CommunityToolkit.Datasync.Server.NSwag.Test.NSwag_Tests.NSwag_GeneratesSwagger~System.Threading.Tasks.Task")] diff --git a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/NSwag_Tests.cs b/tests/CommunityToolkit.Datasync.Server.NSwag.Test/NSwag_Tests.cs index 8893afd..fa83b9d 100644 --- a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/NSwag_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.NSwag.Test/NSwag_Tests.cs @@ -2,9 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +#pragma warning disable SYSLIB1045 // Convert to 'GeneratedRegexAttribute'. + using CommunityToolkit.Datasync.Server.NSwag.Test.Service; using CommunityToolkit.Datasync.TestCommon; -using FluentAssertions.Specialized; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.TestHost; using NSwag; diff --git a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/GlobalSuppressions.cs b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/GlobalSuppressions.cs deleted file mode 100644 index 07fa981..0000000 --- a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/GlobalSuppressions.cs +++ /dev/null @@ -1,7 +0,0 @@ -// This file is used by Code Analysis to maintain SuppressMessage -// attributes that are applied to this project. -// Project-level suppressions either have no target or are given -// a specific target and scoped to a namespace, type, member, etc. - - -[assembly: SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "", Scope = "member", Target = "~M:CommunityToolkit.Datasync.Server.Swashbuckle.Test.Swashbuckle_Tests.NSwag_GeneratesSwagger~System.Threading.Tasks.Task")]