diff --git a/CHANGELOG.md b/CHANGELOG.md index 31189b098..688ce2a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ Prefix your items with `(Template)` if the change is about the template and not ## 3.10.X - Added Dependency Injection validation in the development environment. - Cleaned up the persistence configuration files (removed unused parameters and updated documentation). +- Updated Contributing documentation. - Adding a workaround for a bug with the language change on android. +- Cleanup the 'Access' layer's code (renamed repositories into API clients, removed unused namespaces, and sealed some classes). ## 3.9.X - Removed unnecessary `IsExternalInit.cs` files. @@ -21,7 +23,6 @@ Prefix your items with `(Template)` if the change is about the template and not - Optimized the .NET workloads install process. - Fixed the iOS application icon size. - Added VM Disposal in Functional Tests. -- Updated Contributing documentation. ## 3.8.X - Updated from .NET 8 to .NET 9. diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Authentication/AuthenticationApiClientMock.cs b/src/app/ApplicationTemplate.Access/ApiClients/Authentication/AuthenticationApiClientMock.cs index be36e7d4c..57c2dfc99 100644 --- a/src/app/ApplicationTemplate.Access/ApiClients/Authentication/AuthenticationApiClientMock.cs +++ b/src/app/ApplicationTemplate.Access/ApiClients/Authentication/AuthenticationApiClientMock.cs @@ -10,21 +10,25 @@ namespace ApplicationTemplate.DataAccess; public sealed class AuthenticationApiClientMock : IAuthenticationApiClient { + private const int TokenExpirationSeconds = 600; + private readonly JsonSerializerOptions _serializerOptions; private readonly IOptionsMonitor _mockOptionsMonitor; + private readonly TimeProvider _timeProvider; - public AuthenticationApiClientMock(JsonSerializerOptions serializerOptions, IOptionsMonitor mockOptionsMonitor) + public AuthenticationApiClientMock(JsonSerializerOptions serializerOptions, IOptionsMonitor mockOptionsMonitor, TimeProvider timeProvider) { _serializerOptions = serializerOptions; _mockOptionsMonitor = mockOptionsMonitor; + _timeProvider = timeProvider; } public async Task CreateAccount(CancellationToken ct, string email, string password) { await SimulateDelay(ct); - // We authenticate the user on account creation, since we don't have a backend to register and validate the user - return CreateAuthenticationData(); + // We authenticate the user on account creation, since we don't have a backend to register and validate the user. + return CreateAuthenticationData(email: email); } public async Task ResetPassword(CancellationToken ct, string email) @@ -36,7 +40,7 @@ public async Task Login(CancellationToken ct, string email, { await SimulateDelay(ct); - return CreateAuthenticationData(); + return CreateAuthenticationData(email: email); } public async Task RefreshToken(CancellationToken ct, AuthenticationData unauthorizedToken) @@ -45,41 +49,58 @@ public async Task RefreshToken(CancellationToken ct, Authent await SimulateDelay(ct); - return CreateAuthenticationData(unauthorizedToken.AccessTokenPayload); - } - - private AuthenticationData CreateAuthenticationData(AuthenticationToken token = null, TimeSpan? timeToLive = null) - { - var encodedJwt = CreateJsonWebToken(token, timeToLive); - var jwt = new JwtData(encodedJwt, _serializerOptions); - - return new AuthenticationData() - { - AccessToken = jwt.Token, - RefreshToken = Guid.NewGuid().ToString(format: null, CultureInfo.InvariantCulture), - Expiration = jwt.Payload.Expiration, - }; + return CreateAuthenticationData(token: unauthorizedToken.AccessToken.Payload); } - private string CreateJsonWebToken(AuthenticationToken token = null, TimeSpan? timeToLive = null) + /// + /// Creates a JSON Web Token. + /// + /// + /// This function has been made public and static for testing purposes. + /// + /// The token to use. + /// The email or unique name to store in the token. + /// The current date and time to use for the authentication token. + /// The serializer options to use for the token serialization. + /// The JSON Web token. + public static string CreateJsonWebToken(AuthenticationToken token = null, string email = null, DateTimeOffset? now = null, JsonSerializerOptions serializerOptions = null) { const string header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"; // alg=HS256, type=JWT const string signature = "QWqnPP8W6ymexz74P6quP-oG-wxr7vMGqrEL8y_tV6M"; // dummy stuff - var now = DateTimeOffset.Now; + now ??= DateTimeOffset.Now; - token = token ?? new AuthenticationToken(default, DateTimeOffset.MinValue, DateTimeOffset.MinValue); + token ??= new AuthenticationToken() + { + Email = email, + Expiration = now.Value.AddSeconds(TokenExpirationSeconds), + IssuedAt = now.Value, + }; string payload; using (var stream = new MemoryStream()) { - JsonSerializer.Serialize(stream, token, _serializerOptions); + var test = JsonSerializer.Serialize(token, serializerOptions); + + JsonSerializer.Serialize(stream, token, serializerOptions); payload = Convert.ToBase64String(stream.ToArray()); } return header + '.' + payload + '.' + signature; } + private AuthenticationData CreateAuthenticationData(AuthenticationToken token = null, string email = null) + { + var now = _timeProvider.GetLocalNow(); + var encodedJwt = CreateJsonWebToken(token, email, now, _serializerOptions); + + return new AuthenticationData() + { + AccessToken = new JwtData(encodedJwt, _serializerOptions), + RefreshToken = Guid.NewGuid().ToString(format: null, CultureInfo.InvariantCulture), + }; + } + private async Task SimulateDelay(CancellationToken ct) { if (_mockOptionsMonitor.CurrentValue.IsDelayForSimulatedApiCallsEnabled) diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Authentication/AuthenticationData.cs b/src/app/ApplicationTemplate.Access/ApiClients/Authentication/AuthenticationData.cs deleted file mode 100644 index 8273a8aa6..000000000 --- a/src/app/ApplicationTemplate.Access/ApiClients/Authentication/AuthenticationData.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.Text.Json.Serialization; -using MallardMessageHandlers; - -namespace ApplicationTemplate.DataAccess; - -public class AuthenticationData : IAuthenticationToken -{ - public AuthenticationData( - string accessToken = default, - string refreshToken = default, - DateTimeOffset expiration = default) - { - AccessToken = accessToken; - RefreshToken = refreshToken; - Expiration = expiration; - } - - [JsonIgnore] - public AuthenticationToken AccessTokenPayload => AccessToken == null ? null : new JwtData(AccessToken).Payload; - - [JsonPropertyName("access_token")] - public string AccessToken { get; init; } - - [JsonPropertyName("refresh_token")] - public string RefreshToken { get; init; } - - public DateTimeOffset Expiration { get; init; } - - public string Email => AccessTokenPayload?.Email; - - [JsonIgnore] - public bool CanBeRefreshed => !string.IsNullOrEmpty(RefreshToken); - - [JsonIgnore] - string IAuthenticationToken.AccessToken => AccessToken; -} diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Authentication/AuthenticationToken.cs b/src/app/ApplicationTemplate.Access/ApiClients/Authentication/AuthenticationToken.cs deleted file mode 100644 index 4e3555eb6..000000000 --- a/src/app/ApplicationTemplate.Access/ApiClients/Authentication/AuthenticationToken.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace ApplicationTemplate.DataAccess; - -public class AuthenticationToken -{ - public AuthenticationToken() { } - - public AuthenticationToken(string email, DateTimeOffset expiration, DateTimeOffset issuedAt) - { - Email = email; - Expiration = expiration; - IssuedAt = issuedAt; - } - - [JsonPropertyName("unique_name")] - public string Email { get; set; } - - [JsonPropertyName("exp")] - [JsonConverter(typeof(UnixTimestampJsonConverter))] - public DateTimeOffset Expiration { get; set; } - - [JsonPropertyName("iat")] - [JsonConverter(typeof(UnixTimestampJsonConverter))] - public DateTimeOffset IssuedAt { get; set; } -} diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Authentication/Data/AuthenticationData.cs b/src/app/ApplicationTemplate.Access/ApiClients/Authentication/Data/AuthenticationData.cs new file mode 100644 index 000000000..d84cda2a5 --- /dev/null +++ b/src/app/ApplicationTemplate.Access/ApiClients/Authentication/Data/AuthenticationData.cs @@ -0,0 +1,27 @@ +using System; +using System.Text.Json.Serialization; +using MallardMessageHandlers; + +namespace ApplicationTemplate.DataAccess; + +public sealed class AuthenticationData : IAuthenticationToken +{ + [JsonPropertyName("access_token")] + [JsonConverter(typeof(JwtDataJsonConverter))] + public JwtData AccessToken { get; init; } + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; init; } + + [JsonIgnore] + string IAuthenticationToken.AccessToken => AccessToken?.Token; + + [JsonIgnore] + public bool CanBeRefreshed => !string.IsNullOrEmpty(RefreshToken); + + [JsonIgnore] + public string Email => AccessToken?.Payload?.Email; + + [JsonIgnore] + public DateTimeOffset? Expiration => AccessToken?.Payload?.Expiration; +} diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Authentication/Data/AuthenticationToken.cs b/src/app/ApplicationTemplate.Access/ApiClients/Authentication/Data/AuthenticationToken.cs new file mode 100644 index 000000000..fb99729a4 --- /dev/null +++ b/src/app/ApplicationTemplate.Access/ApiClients/Authentication/Data/AuthenticationToken.cs @@ -0,0 +1,18 @@ +using System; +using System.Text.Json.Serialization; + +namespace ApplicationTemplate.DataAccess; + +public sealed class AuthenticationToken +{ + [JsonPropertyName("unique_name")] + public string Email { get; init; } + + [JsonPropertyName("exp")] + [JsonConverter(typeof(UnixTimestampJsonConverter))] + public DateTimeOffset Expiration { get; init; } + + [JsonPropertyName("iat")] + [JsonConverter(typeof(UnixTimestampJsonConverter))] + public DateTimeOffset IssuedAt { get; init; } +} diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Authentication/IAuthenticationApiClient.cs b/src/app/ApplicationTemplate.Access/ApiClients/Authentication/IAuthenticationApiClient.cs index 7e53d85eb..36a85c33a 100644 --- a/src/app/ApplicationTemplate.Access/ApiClients/Authentication/IAuthenticationApiClient.cs +++ b/src/app/ApplicationTemplate.Access/ApiClients/Authentication/IAuthenticationApiClient.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; +using System.Threading; using System.Threading.Tasks; namespace ApplicationTemplate.DataAccess; diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Posts/Data/ErrorData.cs b/src/app/ApplicationTemplate.Access/ApiClients/Posts/Data/ErrorData.cs new file mode 100644 index 000000000..41188935f --- /dev/null +++ b/src/app/ApplicationTemplate.Access/ApiClients/Posts/Data/ErrorData.cs @@ -0,0 +1,5 @@ +namespace ApplicationTemplate.DataAccess; + +public sealed class ErrorData +{ +} diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostData.cs b/src/app/ApplicationTemplate.Access/ApiClients/Posts/Data/PostData.cs similarity index 80% rename from src/app/ApplicationTemplate.Access/ApiClients/Posts/PostData.cs rename to src/app/ApplicationTemplate.Access/ApiClients/Posts/Data/PostData.cs index b6c015b0c..1d6352c9c 100644 --- a/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostData.cs +++ b/src/app/ApplicationTemplate.Access/ApiClients/Posts/Data/PostData.cs @@ -1,11 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace ApplicationTemplate.DataAccess; -public record PostData +public sealed record PostData { public PostData(long id, string title, string body, long userIdentifier) { diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Posts/Data/PostErrorResponse.cs b/src/app/ApplicationTemplate.Access/ApiClients/Posts/Data/PostErrorResponse.cs new file mode 100644 index 000000000..b9b2d4052 --- /dev/null +++ b/src/app/ApplicationTemplate.Access/ApiClients/Posts/Data/PostErrorResponse.cs @@ -0,0 +1,8 @@ +namespace ApplicationTemplate.DataAccess; + +public sealed class PostErrorResponse +{ + public PostData Data { get; } + + public ErrorData Error { get; } +} diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Posts/ErrorData.cs b/src/app/ApplicationTemplate.Access/ApiClients/Posts/ErrorData.cs deleted file mode 100644 index 22417d3ca..000000000 --- a/src/app/ApplicationTemplate.Access/ApiClients/Posts/ErrorData.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace ApplicationTemplate.DataAccess; - -public class ErrorData -{ -} diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Posts/IPostRepository.cs b/src/app/ApplicationTemplate.Access/ApiClients/Posts/IPostsApiClient.cs similarity index 93% rename from src/app/ApplicationTemplate.Access/ApiClients/Posts/IPostRepository.cs rename to src/app/ApplicationTemplate.Access/ApiClients/Posts/IPostsApiClient.cs index 963abef83..89909f574 100644 --- a/src/app/ApplicationTemplate.Access/ApiClients/Posts/IPostRepository.cs +++ b/src/app/ApplicationTemplate.Access/ApiClients/Posts/IPostsApiClient.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; +using System.Threading; using System.Threading.Tasks; using Refit; @@ -11,7 +8,7 @@ namespace ApplicationTemplate.DataAccess; /// Provides access to the posts API. /// [Headers("Authorization: Bearer")] -public interface IPostsRepository +public interface IPostsApiClient { /// /// Gets the list of all posts. diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostErrorResponse.cs b/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostErrorResponse.cs deleted file mode 100644 index 444688fd4..000000000 --- a/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostErrorResponse.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace ApplicationTemplate.DataAccess; - -public class PostErrorResponse -{ - public PostData Data { get; } - - public ErrorData Error { get; } -} diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostRepositoryException.cs b/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostRepositoryException.cs deleted file mode 100644 index ddb5c86e9..000000000 --- a/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostRepositoryException.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace ApplicationTemplate.DataAccess; - -public class PostRepositoryException : Exception -{ - public PostRepositoryException() - { - } - - public PostRepositoryException(string message) - : base(message) - { - } - - public PostRepositoryException(string message, Exception innerException) - : base(message, innerException) - { - } - - public PostRepositoryException(PostErrorResponse errorResponse) - { - } -} diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostsApiClientException.cs b/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostsApiClientException.cs new file mode 100644 index 000000000..c66411f90 --- /dev/null +++ b/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostsApiClientException.cs @@ -0,0 +1,24 @@ +using System; + +namespace ApplicationTemplate.DataAccess; + +public sealed class PostsApiClientException : Exception +{ + public PostsApiClientException() + { + } + + public PostsApiClientException(string message) + : base(message) + { + } + + public PostsApiClientException(string message, Exception innerException) + : base(message, innerException) + { + } + + public PostsApiClientException(PostErrorResponse errorResponse) + { + } +} diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostsRepositoryMock.GetAll.json b/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostsApiClientMock.GetAll.json similarity index 100% rename from src/app/ApplicationTemplate.Access/ApiClients/Posts/PostsRepositoryMock.GetAll.json rename to src/app/ApplicationTemplate.Access/ApiClients/Posts/PostsApiClientMock.GetAll.json diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostsRepositoryMock.cs b/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostsApiClientMock.cs similarity index 75% rename from src/app/ApplicationTemplate.Access/ApiClients/Posts/PostsRepositoryMock.cs rename to src/app/ApplicationTemplate.Access/ApiClients/Posts/PostsApiClientMock.cs index d67ea808d..7d5c97f71 100644 --- a/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostsRepositoryMock.cs +++ b/src/app/ApplicationTemplate.Access/ApiClients/Posts/PostsApiClientMock.cs @@ -1,16 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Text.Json; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Refit; namespace ApplicationTemplate.DataAccess; -public class PostsRepositoryMock : BaseMock, IPostsRepository +public sealed class PostsApiClientMock : BaseMock, IPostsApiClient { - public PostsRepositoryMock(JsonSerializerOptions serializerOptions) + public PostsApiClientMock(JsonSerializerOptions serializerOptions) : base(serializerOptions) { } diff --git a/src/app/ApplicationTemplate.Access/ApiClients/UserProfile/IUserProfileRepository.cs b/src/app/ApplicationTemplate.Access/ApiClients/UserProfile/IUserProfileApiClient.cs similarity index 84% rename from src/app/ApplicationTemplate.Access/ApiClients/UserProfile/IUserProfileRepository.cs rename to src/app/ApplicationTemplate.Access/ApiClients/UserProfile/IUserProfileApiClient.cs index f2083776b..f040af9f1 100644 --- a/src/app/ApplicationTemplate.Access/ApiClients/UserProfile/IUserProfileRepository.cs +++ b/src/app/ApplicationTemplate.Access/ApiClients/UserProfile/IUserProfileApiClient.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; +using System.Threading; using System.Threading.Tasks; using Refit; @@ -11,7 +8,7 @@ namespace ApplicationTemplate.DataAccess; /// Provides access to the user profile API. /// [Headers("Authorization: Bearer")] -public interface IUserProfileRepository +public interface IUserProfileApiClient { /// /// Returns the current user profile. diff --git a/src/app/ApplicationTemplate.Access/ApiClients/UserProfile/UserProfileRepositoryMock.Get.json b/src/app/ApplicationTemplate.Access/ApiClients/UserProfile/UserProfileApiClientMock.Get.json similarity index 100% rename from src/app/ApplicationTemplate.Access/ApiClients/UserProfile/UserProfileRepositoryMock.Get.json rename to src/app/ApplicationTemplate.Access/ApiClients/UserProfile/UserProfileApiClientMock.Get.json diff --git a/src/app/ApplicationTemplate.Access/ApiClients/UserProfile/UserProfileRepositoryMock.cs b/src/app/ApplicationTemplate.Access/ApiClients/UserProfile/UserProfileApiClientMock.cs similarity index 89% rename from src/app/ApplicationTemplate.Access/ApiClients/UserProfile/UserProfileRepositoryMock.cs rename to src/app/ApplicationTemplate.Access/ApiClients/UserProfile/UserProfileApiClientMock.cs index b26aa5b5e..95b6d47d5 100644 --- a/src/app/ApplicationTemplate.Access/ApiClients/UserProfile/UserProfileRepositoryMock.cs +++ b/src/app/ApplicationTemplate.Access/ApiClients/UserProfile/UserProfileApiClientMock.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -9,12 +7,12 @@ namespace ApplicationTemplate.DataAccess; -public class UserProfileRepositoryMock : BaseMock, IUserProfileRepository +public sealed class UserProfileApiClientMock : BaseMock, IUserProfileApiClient { private readonly IAuthenticationTokenProvider _tokenProvider; private readonly IOptionsMonitor _mockOptionsMonitor; - public UserProfileRepositoryMock( + public UserProfileApiClientMock( IAuthenticationTokenProvider tokenProvider, JsonSerializerOptions serializerOptions, IOptionsMonitor mockOptionsMonitor) diff --git a/src/app/ApplicationTemplate.Access/ApplicationTemplate.Access.csproj b/src/app/ApplicationTemplate.Access/ApplicationTemplate.Access.csproj index 656df5b5c..2d9144df0 100644 --- a/src/app/ApplicationTemplate.Access/ApplicationTemplate.Access.csproj +++ b/src/app/ApplicationTemplate.Access/ApplicationTemplate.Access.csproj @@ -6,12 +6,15 @@ ApplicationTemplate.DataAccess true true + + + $(NoWarn);SYSLIB0020 - - + + diff --git a/src/app/ApplicationTemplate.Access/Configuration/SerializationConfiguration.cs b/src/app/ApplicationTemplate.Access/Configuration/SerializationConfiguration.cs index eff0cd231..f8136a4b3 100644 --- a/src/app/ApplicationTemplate.Access/Configuration/SerializationConfiguration.cs +++ b/src/app/ApplicationTemplate.Access/Configuration/SerializationConfiguration.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; diff --git a/src/app/ApplicationTemplate.Access/Framework/Serialization/JwtDataJsonConverter.cs b/src/app/ApplicationTemplate.Access/Framework/Serialization/JwtDataJsonConverter.cs index 511b0f4f0..f453ecae0 100644 --- a/src/app/ApplicationTemplate.Access/Framework/Serialization/JwtDataJsonConverter.cs +++ b/src/app/ApplicationTemplate.Access/Framework/Serialization/JwtDataJsonConverter.cs @@ -1,12 +1,10 @@ using System; -using System.Collections.Generic; -using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace ApplicationTemplate; -public class JwtDataJsonConverter : JsonConverter> +public sealed class JwtDataJsonConverter : JsonConverter> where TPayload : class { public override JwtData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/src/app/ApplicationTemplate.Access/LocalStorage/ApplicationSettings.cs b/src/app/ApplicationTemplate.Access/LocalStorage/ApplicationSettings.cs index 6637c28d2..cd80abf4c 100644 --- a/src/app/ApplicationTemplate.Access/LocalStorage/ApplicationSettings.cs +++ b/src/app/ApplicationTemplate.Access/LocalStorage/ApplicationSettings.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Immutable; +using System.Collections.Immutable; namespace ApplicationTemplate.DataAccess; diff --git a/src/app/ApplicationTemplate.Business/Posts/PostService.cs b/src/app/ApplicationTemplate.Business/Posts/PostService.cs index 898c9017f..bacf45e8d 100644 --- a/src/app/ApplicationTemplate.Business/Posts/PostService.cs +++ b/src/app/ApplicationTemplate.Business/Posts/PostService.cs @@ -8,9 +8,9 @@ namespace ApplicationTemplate.Business; public partial class PostService : IPostService { - private readonly IPostsRepository _postsRepository; + private readonly IPostsApiClient _postsRepository; - public PostService(IPostsRepository postsRepository) + public PostService(IPostsApiClient postsRepository) { _postsRepository = postsRepository; } diff --git a/src/app/ApplicationTemplate.Business/UserProfile/UserProfileService.cs b/src/app/ApplicationTemplate.Business/UserProfile/UserProfileService.cs index 22fbb5909..858b47cdb 100644 --- a/src/app/ApplicationTemplate.Business/UserProfile/UserProfileService.cs +++ b/src/app/ApplicationTemplate.Business/UserProfile/UserProfileService.cs @@ -12,9 +12,9 @@ namespace ApplicationTemplate.Business; public partial class UserProfileService : IUserProfileService { - private readonly IUserProfileRepository _profileRepository; + private readonly IUserProfileApiClient _profileRepository; - public UserProfileService(IUserProfileRepository profileRepository) + public UserProfileService(IUserProfileApiClient profileRepository) { _profileRepository = profileRepository ?? throw new ArgumentNullException(nameof(profileRepository)); } diff --git a/src/app/ApplicationTemplate.Presentation/Configuration/ApiConfiguration.cs b/src/app/ApplicationTemplate.Presentation/Configuration/ApiConfiguration.cs index 3891804e8..23ed48dd8 100644 --- a/src/app/ApplicationTemplate.Presentation/Configuration/ApiConfiguration.cs +++ b/src/app/ApplicationTemplate.Presentation/Configuration/ApiConfiguration.cs @@ -53,7 +53,7 @@ public static IServiceCollection AddApi(this IServiceCollection services, IConfi private static IServiceCollection AddUserProfile(this IServiceCollection services) { // This one doesn't have an actual remote API yet. It's always a mock implementation. - return services.AddSingleton(); + return services.AddSingleton(); } private static IServiceCollection AddMinimumVersion(this IServiceCollection services) @@ -79,10 +79,10 @@ private static IServiceCollection AddPosts(this IServiceCollection services, ICo return services .AddSingleton>(s => new ErrorResponseInterpreter( (request, response, deserializedResponse) => deserializedResponse.Error != null, - (request, response, deserializedResponse) => new PostRepositoryException(deserializedResponse) + (request, response, deserializedResponse) => new PostsApiClientException(deserializedResponse) )) .AddTransient>() - .AddApiClient(configuration, "PostApiClient", b => b + .AddApiClient(configuration, "PostApiClient", b => b .AddHttpMessageHandler>() .AddHttpMessageHandler>() ); diff --git a/src/app/ApplicationTemplate.Presentation/Configuration/AppServicesConfiguration.cs b/src/app/ApplicationTemplate.Presentation/Configuration/AppServicesConfiguration.cs index 7259cd6a8..2475600f2 100644 --- a/src/app/ApplicationTemplate.Presentation/Configuration/AppServicesConfiguration.cs +++ b/src/app/ApplicationTemplate.Presentation/Configuration/AppServicesConfiguration.cs @@ -1,4 +1,5 @@ -using System.Reactive.Concurrency; +using System; +using System.Reactive.Concurrency; using ApplicationTemplate.Business; using ApplicationTemplate.DataAccess; using ApplicationTemplate.Presentation; @@ -22,6 +23,7 @@ public static class AppServicesConfiguration public static IServiceCollection AddAppServices(this IServiceCollection services) { return services + .AddSingleton(TimeProvider.System) .AddSingleton() .AddSingleton(s => TaskPoolScheduler.Default.ToBackgroundScheduler()) .AddSingleton() diff --git a/src/app/ApplicationTemplate.Tests.Functional/ApplicationTemplate.Tests.Functional.csproj b/src/app/ApplicationTemplate.Tests.Functional/ApplicationTemplate.Tests.Functional.csproj index a66768094..f8e9d1331 100644 --- a/src/app/ApplicationTemplate.Tests.Functional/ApplicationTemplate.Tests.Functional.csproj +++ b/src/app/ApplicationTemplate.Tests.Functional/ApplicationTemplate.Tests.Functional.csproj @@ -12,6 +12,7 @@ + diff --git a/src/app/ApplicationTemplate.Tests.Unit/ApplicationTemplate.Tests.Unit.csproj b/src/app/ApplicationTemplate.Tests.Unit/ApplicationTemplate.Tests.Unit.csproj index eb5cf211c..2a978bded 100644 --- a/src/app/ApplicationTemplate.Tests.Unit/ApplicationTemplate.Tests.Unit.csproj +++ b/src/app/ApplicationTemplate.Tests.Unit/ApplicationTemplate.Tests.Unit.csproj @@ -11,6 +11,7 @@ + diff --git a/src/app/ApplicationTemplate.Tests.Unit/AuthenticationServiceShould.cs b/src/app/ApplicationTemplate.Tests.Unit/AuthenticationServiceShould.cs new file mode 100644 index 000000000..3867f3192 --- /dev/null +++ b/src/app/ApplicationTemplate.Tests.Unit/AuthenticationServiceShould.cs @@ -0,0 +1,192 @@ +using System; +using System.Net.Http; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Threading; +using System.Threading.Tasks; +using ApplicationTemplate.Business; +using ApplicationTemplate.DataAccess; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Uno.Extensions; +using Xunit; + +namespace ApplicationTemplate.Tests; + +/// +/// Tests for the . +/// +public sealed class AuthenticationServiceShould : IDisposable +{ + private readonly IAuthenticationService _authenticationService; + private readonly IApplicationSettingsRepository _applicationSettingsRepository; + private readonly IAuthenticationApiClient _authenticationApiClient; + private readonly BehaviorSubject _applicationSettingsSubject; + + public AuthenticationServiceShould() + { + _applicationSettingsRepository = Substitute.For(); + _authenticationApiClient = Substitute.For(); + + _authenticationService = new AuthenticationService( + Substitute.For(), + _applicationSettingsRepository, + _authenticationApiClient + ); + + _applicationSettingsSubject = new BehaviorSubject(CreateApplicationSettings()); + + _applicationSettingsRepository.GetAndObserveCurrent().Returns(_applicationSettingsSubject); + _applicationSettingsRepository.GetCurrent(CancellationToken.None).Returns(Task.FromResult(_applicationSettingsSubject.Value)); + + _applicationSettingsRepository.When(x => x.SetAuthenticationData(Arg.Any(), Arg.Any())) + .Do(x => _applicationSettingsSubject.OnNext(CreateApplicationSettings(x.Arg()))); + } + + [Fact] + public async Task ReturnAuthenticationData_WhenLoggingIn() + { + // Arrange + var email = "email"; + var password = "password"; + var authenticationData = CreateAuthenticationData(email: email); + + _authenticationApiClient.Login(Arg.Any(), Arg.Any(), Arg.Any()).Returns(authenticationData); + + // Act + var result = await _authenticationService.Login(CancellationToken.None, email, password); + + // Assert + Assert.Equal(authenticationData.AccessToken, result.AccessToken); + } + + [Fact] + public async Task ReturnAuthenticationData_WhenRefreshingTheToken() + { + // Arrange + var email = "email"; + var password = "password"; + var now = DateTimeOffset.Now; + var authenticationData = CreateAuthenticationData(email: email, now: now); + + now = now.Add(TimeSpan.FromMinutes(1)); // Advance the time to simulate the token expiration. + + var refreshAuthenticationData = CreateAuthenticationData(email: email, now: now); + + _authenticationApiClient.Login(Arg.Any(), Arg.Any(), Arg.Any()).Returns(authenticationData); + _authenticationApiClient.RefreshToken(Arg.Any(), Arg.Any()).Returns(refreshAuthenticationData); + + // Act + var loginResult = await _authenticationService.Login(CancellationToken.None, email, password); + var result = await _authenticationService.RefreshToken(CancellationToken.None, new HttpRequestMessage(), loginResult); + + // Assert + Assert.True(result.AccessToken.Payload.IssuedAt > loginResult.AccessToken.Payload.IssuedAt); + } + + [Fact] + public async Task NotifySuscribersThatTheSessionExpired_WhenNotifySesionExpiredIsCalled() + { + // Arrange + var notifySessionExpired = false; + + _authenticationService.ObserveSessionExpired().Subscribe(_ => notifySessionExpired = true); + + // Act + await _authenticationService.NotifySessionExpired(CancellationToken.None, new HttpRequestMessage(), CreateAuthenticationData("email")); + + // Assert + Assert.True(notifySessionExpired); + } + + [Fact] + public async Task DiscardUserSettings_OnLogout() + { + // Arrange + // Act + await _authenticationService.Logout(CancellationToken.None); + + // Assert + await _applicationSettingsRepository.Received().DiscardUserSettings(CancellationToken.None); + } + + [Fact] + public async Task SignalThatTheUserIsLoggedOut_WhenThereIsNoAuthenticationDataInAppSettings() + { + // Arrange + ResetApplicationSettings(); + + // Act + var result = await _authenticationService.GetAndObserveIsAuthenticated().FirstAsync(); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ReturnNull_WhenThereIsNoAuthenticationDataInAppSettings() + { + // Arrange + ResetApplicationSettings(); + + // Act + var result = await _authenticationService.GetAndObserveAuthenticationData().FirstAsync(); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task SignalThatUserIsLoggedIn_WhenTheSettingsContainsAuthenticationData() + { + // Arrange + var authenticationData = CreateAuthenticationData("email"); + + _applicationSettingsSubject.OnNext(CreateApplicationSettings(authenticationData)); + + // Act + var result = await _authenticationService.GetAndObserveIsAuthenticated().FirstAsync(); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task GiveAuthData_WhenTheSettingsContainsAuthenticationData() + { + // Arrange + var authenticationData = CreateAuthenticationData("email"); + + _applicationSettingsSubject.OnNext(CreateApplicationSettings(authenticationData)); + + // Act + var result = await _authenticationService.GetAndObserveAuthenticationData().FirstAsync(); + + // Assert + Assert.Equal(authenticationData.AccessToken, result.AccessToken); + } + + public void Dispose() + { + _applicationSettingsSubject.Dispose(); + } + + private void ResetApplicationSettings() + { + _applicationSettingsSubject.OnNext(CreateApplicationSettings()); + } + + private AuthenticationData CreateAuthenticationData(string email, DateTimeOffset? now = null) + { + return new AuthenticationData + { + AccessToken = new JwtData(AuthenticationApiClientMock.CreateJsonWebToken(email: email, now: now, serializerOptions: SerializationConfiguration.DefaultJsonSerializerOptions)), + RefreshToken = "RefreshToken", + }; + } + + private ApplicationSettings CreateApplicationSettings(AuthenticationData authenticationData = null) + { + return new ApplicationSettings() { AuthenticationData = authenticationData }; + } +} diff --git a/src/app/ApplicationTemplate.Tests.Unit/Posts/PostServiceShould.cs b/src/app/ApplicationTemplate.Tests.Unit/Posts/PostServiceShould.cs index 54010dd2a..04741f5d6 100644 --- a/src/app/ApplicationTemplate.Tests.Unit/Posts/PostServiceShould.cs +++ b/src/app/ApplicationTemplate.Tests.Unit/Posts/PostServiceShould.cs @@ -18,7 +18,7 @@ public sealed partial class PostServiceShould public async Task GetAllPosts() { // Arrange - var mockedPostsRepository = Substitute.For(); + var mockedPostsRepository = Substitute.For(); mockedPostsRepository .GetAll(Arg.Any()) .Returns(Task.FromResult(GetMockedPosts().ToArray())); @@ -38,7 +38,7 @@ public async Task GetAllPosts() public async Task GetPost_WhenGivenIdIsValid(long givenId) { // Arrange - var mockedPostsRepository = Substitute.For(); + var mockedPostsRepository = Substitute.For(); mockedPostsRepository .Get(Arg.Any(), givenId) .Returns(Task.FromResult(GetMockedPost(givenId))); @@ -67,7 +67,7 @@ public async Task GetPost_WhenGivenIdIsValid(long givenId) public async Task GetPostThrowException_WhenGivenIdIsInvalid(int givenId) { // Arrange - var mockedPostsRepository = Substitute.For(); + var mockedPostsRepository = Substitute.For(); mockedPostsRepository .Get(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(GetMockedPosts() @@ -99,7 +99,7 @@ public async Task CreatePost() var randomId = new Random().Next(1, int.MaxValue); - var mockedPostsRepository = Substitute.For(); + var mockedPostsRepository = Substitute.For(); mockedPostsRepository .Create(Arg.Any(), Arg.Any()) .Returns(Task.FromResult((post with { Id = randomId }).ToData())); @@ -131,7 +131,7 @@ public async Task ReturnNull_WhenCreatePostFailed() // Arrange var post = new Post { Title = "My title", Body = "My body", UserIdentifier = 100 }; - var mockedPostsRepository = Substitute.For(); + var mockedPostsRepository = Substitute.For(); mockedPostsRepository .Create(Arg.Any(), post.ToData()) .Returns(Task.FromResult(default(PostData))); @@ -156,7 +156,7 @@ public async Task ReturnNull_WhenCreatePostBodyIsNull() // This part is the part that must be defined by the API contract. // Since there is none here, we are assuming it's giving us a null object when the body is null - var mockedPostsRepository = Substitute.For(); + var mockedPostsRepository = Substitute.For(); mockedPostsRepository .Create(Arg.Any(), post.ToData()) .Returns(Task.FromResult(default(PostData))); @@ -179,7 +179,7 @@ public async Task Update_WhenGivenPostAlreadyExists() // This part is the part that must be defined by the API contract. // Since there is none here, we are assuming it's giving us a null object when the body is null - var mockedPostsRepository = Substitute.For(); + var mockedPostsRepository = Substitute.For(); mockedPostsRepository .Update(Arg.Any(), post.Id, post.ToData()) .Returns(Task.FromResult(post.ToData())); @@ -211,7 +211,7 @@ public async Task DeletePost_WhenGivenPostExists() // Arrange var postId = 1; - var mockedPostsRepository = Substitute.For(); + var mockedPostsRepository = Substitute.For(); mockedPostsRepository .Delete(Arg.Any(), postId) .Returns(Task.CompletedTask); diff --git a/src/app/ApplicationTemplate.Tests.Unit/UserProfile/UserProfileServiceShould.cs b/src/app/ApplicationTemplate.Tests.Unit/UserProfile/UserProfileServiceShould.cs index 0a02940b4..eac344381 100644 --- a/src/app/ApplicationTemplate.Tests.Unit/UserProfile/UserProfileServiceShould.cs +++ b/src/app/ApplicationTemplate.Tests.Unit/UserProfile/UserProfileServiceShould.cs @@ -19,7 +19,7 @@ public sealed partial class UserProfileServiceShould public async Task GetCurrentProfile() { // Arrange - var mockedUserProfileRepository = Substitute.For(); + var mockedUserProfileRepository = Substitute.For(); mockedUserProfileRepository .Get(Arg.Any()) .Returns(Task.FromResult(GetMockedUserProfile())); @@ -41,7 +41,7 @@ public async Task UpdateProfile_GivenAValidUserProfile() new UserProfile { Id = "12345", FirstName = "Nventive", LastName = "Nventive", Email = "nventive@nventive.ca" }; // Arrange - var mockedUserProfileRepository = Substitute.For(); + var mockedUserProfileRepository = Substitute.For(); mockedUserProfileRepository .Get(Arg.Any()) .Returns(Task.FromResult(userProfile.ToData()));