From d430d14374ec99ffb0b4602f0ee9f5a6eedb4c54 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Wed, 27 May 2026 01:07:42 +0100 Subject: [PATCH 1/7] test(wizard): add failing unit tests for #23 RED Co-Authored-By: Claude Sonnet 4.6 --- .../AStar.Dev.CloudSyncFunctional.csproj | 17 + .../App.axaml.cs | 49 +- .../Auth/AccountProfile.cs | 6 + .../Auth/AuthError.cs | 40 ++ .../Auth/AuthResult.cs | 29 ++ .../Auth/AuthService.cs | 25 + .../Auth/IAuthService.cs | 28 ++ .../Auth/ITokenCacheService.cs | 13 + .../Auth/TokenCacheService.cs | 47 ++ .../Domain/OneDriveAccount.cs | 22 + .../Graph/DriveFolder.cs | 7 + .../Graph/GraphClientFactory.cs | 26 ++ .../Graph/GraphError.cs | 95 ++++ .../Graph/GraphService.cs | 12 + .../Graph/IGraphClientFactory.cs | 12 + .../Graph/IGraphService.cs | 14 + .../InternalsVisibleTo.cs | 3 + .../MainWindow.axaml | 26 +- .../MainWindow.axaml.cs | 12 +- .../Onboarding/AccountOnboardingService.cs | 13 + .../Onboarding/IAccountOnboardingService.cs | 14 + .../Onboarding/PersistenceError.cs | 19 + .../Wizard/AddAccountWizardView.axaml | 201 ++++++++ .../Wizard/AddAccountWizardView.axaml.cs | 10 + .../Wizard/AddAccountWizardViewModel.cs | 152 ++++++ .../Wizard/WizardFolderItem.cs | 20 + .../Wizard/WizardStep.cs | 14 + .../Workspace/WorkspaceViewModel.cs | 40 +- .../ResultExtensions.cs | 75 ++- src/AStar.Dev.FunctionalParadigm/Unit.cs | 8 + ....Dev.CloudSyncFunctional.Tests.Unit.csproj | 7 +- .../Auth/GivenAnAuthError.cs | 55 +++ .../Auth/GivenAnAuthResult.cs | 59 +++ .../FunctionalParadigm/GivenAUnit.cs | 27 ++ .../FunctionalParadigm/GivenMatchAsync.cs | 108 +++++ .../Graph/GivenAGraphError.cs | 62 +++ .../Infrastructure/ReactiveUiFixture.cs | 14 + .../GivenAnAccountOnboardingService.cs | 68 +++ .../GivenAnAddAccountWizardViewModel.cs | 440 ++++++++++++++++++ .../Workspace/GivenAWorkspaceViewModel.cs | 115 ++++- 40 files changed, 1967 insertions(+), 37 deletions(-) create mode 100644 src/AStar.Dev.CloudSyncFunctional/Auth/AccountProfile.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Auth/AuthError.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Auth/AuthResult.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Auth/IAuthService.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Auth/ITokenCacheService.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Auth/TokenCacheService.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Domain/OneDriveAccount.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Graph/DriveFolder.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Graph/GraphClientFactory.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Graph/GraphError.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Graph/IGraphClientFactory.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Graph/IGraphService.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/InternalsVisibleTo.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Onboarding/IAccountOnboardingService.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Onboarding/PersistenceError.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml create mode 100644 src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardViewModel.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Wizard/WizardFolderItem.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Wizard/WizardStep.cs create mode 100644 src/AStar.Dev.FunctionalParadigm/Unit.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthError.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthResult.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Unit/FunctionalParadigm/GivenAUnit.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Unit/FunctionalParadigm/GivenMatchAsync.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Graph/GivenAGraphError.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Infrastructure/ReactiveUiFixture.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Onboarding/GivenAnAccountOnboardingService.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Wizard/GivenAnAddAccountWizardViewModel.cs diff --git a/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj b/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj index b1629d6..1319ba8 100644 --- a/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj +++ b/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj @@ -2,9 +2,11 @@ WinExe net10.0 + enable enable app.manifest true + NU1903 @@ -18,9 +20,24 @@ None All + + + + + + + + + + + + + + + diff --git a/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs index 5fabeac..8a8bba4 100644 --- a/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs @@ -1,23 +1,56 @@ +using AStar.Dev.CloudSyncFunctional.Auth; +using AStar.Dev.CloudSyncFunctional.Graph; +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Wizard; +using AStar.Dev.CloudSyncFunctional.Workspace; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Client; +using MELogLevel = Microsoft.Extensions.Logging.LogLevel; namespace AStar.Dev.CloudSyncFunctional; +/// Application entry point and DI composition root. public partial class App : Application { - public override void Initialize() - { - AvaloniaXamlLoader.Load(this); - } + private IServiceProvider? _serviceProvider; + + /// + public override void Initialize() => AvaloniaXamlLoader.Load(this); + /// public override void OnFrameworkInitializationCompleted() { + var services = new ServiceCollection(); + ConfigureServices(services); + _serviceProvider = services.BuildServiceProvider(); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - desktop.MainWindow = new MainWindow(); - } + desktop.MainWindow = new MainWindow(_serviceProvider.GetRequiredService()); base.OnFrameworkInitializationCompleted(); } -} \ No newline at end of file + + private static void ConfigureServices(IServiceCollection services) + { + services.AddLogging(builder => builder.AddConsole().SetMinimumLevel(MELogLevel.Debug)); + + services.AddSingleton(_ => + PublicClientApplicationBuilder + .Create("00000000-0000-0000-0000-000000000000") + .WithAuthority("https://login.microsoftonline.com/consumers") + .WithRedirectUri("http://localhost") + .Build()); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/AccountProfile.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/AccountProfile.cs new file mode 100644 index 0000000..4d9aba7 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/AccountProfile.cs @@ -0,0 +1,6 @@ +namespace AStar.Dev.CloudSyncFunctional.Auth; + +/// Profile information extracted from a successful authentication result. +/// The user's display name. +/// The user's email address. +public sealed record AccountProfile(string DisplayName, string Email); diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthError.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthError.cs new file mode 100644 index 0000000..e82aa9e --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthError.cs @@ -0,0 +1,40 @@ +namespace AStar.Dev.CloudSyncFunctional.Auth; + +/// Base type for authentication errors. +public abstract record AuthError +{ + /// Gets the human-readable error message. + public abstract string Message { get; } +} + +/// Represents a cancelled authentication attempt. +public sealed record AuthCancelledError : AuthError +{ + /// + public override string Message => "Authentication was cancelled."; +} + +/// Represents a failed authentication attempt with a specific reason. +public sealed record AuthFailedError : AuthError +{ + /// + public override string Message { get; } + + /// Initialises a new with the given message. + /// The error message describing the failure. + public AuthFailedError(string message) => Message = message; +} + +/// Creates instances. +public static class AuthErrorFactory +{ + /// Creates an . + /// An error representing a cancelled authentication. + public static AuthError Cancelled() => new AuthCancelledError(); + + /// Creates an with the given message. + /// The error message; falls back to a default if null or whitespace. + /// An error representing a failed authentication. + public static AuthError Failed(string? message) => new AuthFailedError( + string.IsNullOrWhiteSpace(message) ? "Authentication failed: unknown error." : message); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthResult.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthResult.cs new file mode 100644 index 0000000..9b9d8d0 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthResult.cs @@ -0,0 +1,29 @@ +namespace AStar.Dev.CloudSyncFunctional.Auth; + +/// The result of a successful authentication with Microsoft. +/// The OAuth2 access token for Graph API calls. +/// The MSAL HomeAccountId identifier. +/// The authenticated user's profile. +/// When the access token expires. +public sealed record AuthResult(string AccessToken, string AccountId, AccountProfile Profile, DateTimeOffset ExpiresOn); + +/// Creates instances with validated inputs. +public static class AuthResultFactory +{ + /// Creates a validated . + /// The OAuth2 access token. + /// The MSAL account identifier. + /// The user profile. + /// The token expiry time. + /// A new . + /// Thrown when or is null or whitespace. + /// Thrown when is null. + public static AuthResult Create(string accessToken, string accountId, AccountProfile profile, DateTimeOffset expiresOn) + { + ArgumentException.ThrowIfNullOrWhiteSpace(accessToken); + ArgumentException.ThrowIfNullOrWhiteSpace(accountId); + ArgumentNullException.ThrowIfNull(profile); + + return new AuthResult(accessToken, accountId, profile, expiresOn); + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs new file mode 100644 index 0000000..6f105e8 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs @@ -0,0 +1,25 @@ +using AStar.Dev.FunctionalParadigm; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Client; + +namespace AStar.Dev.CloudSyncFunctional.Auth; + +/// +public sealed partial class AuthService(IPublicClientApplication app, ILogger logger) : IAuthService +{ + /// + public Task> SignInInteractiveAsync(CancellationToken ct = default) + => throw new NotImplementedException(); + + /// + public Task> AcquireTokenSilentAsync(string accountId, CancellationToken ct = default) + => throw new NotImplementedException(); + + /// + public Task SignOutAsync(string accountId, CancellationToken ct = default) + => throw new NotImplementedException(); + + /// + public Task> GetCachedAccountIdsAsync() + => throw new NotImplementedException(); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/IAuthService.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/IAuthService.cs new file mode 100644 index 0000000..aa12414 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/IAuthService.cs @@ -0,0 +1,28 @@ +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Auth; + +/// Provides MSAL-based authentication for Microsoft accounts. +public interface IAuthService +{ + /// Opens an interactive browser sign-in and returns the authenticated result. + /// Token to cancel the operation. + /// An on success, or an on failure. + Task> SignInInteractiveAsync(CancellationToken ct = default); + + /// Silently acquires a new access token for an existing cached account. + /// The MSAL HomeAccountId identifier. + /// Token to cancel the operation. + /// An on success, or an on failure. + Task> AcquireTokenSilentAsync(string accountId, CancellationToken ct = default); + + /// Signs out and removes the account from the token cache. + /// The MSAL HomeAccountId identifier. + /// Token to cancel the operation. + /// A task that completes when sign-out is done. + Task SignOutAsync(string accountId, CancellationToken ct = default); + + /// Returns the list of account IDs currently in the token cache. + /// A read-only list of MSAL HomeAccountId identifiers. + Task> GetCachedAccountIdsAsync(); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/ITokenCacheService.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/ITokenCacheService.cs new file mode 100644 index 0000000..fa26a4c --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/ITokenCacheService.cs @@ -0,0 +1,13 @@ +using Microsoft.Identity.Client; + +namespace AStar.Dev.CloudSyncFunctional.Auth; + +/// Registers the MSAL token cache with a persistent backing store. +public interface ITokenCacheService +{ + /// Registers the cache helper with the given MSAL application. + /// The MSAL application whose token cache is registered. + /// Token to cancel the operation. + /// A task that completes when registration is done. + Task RegisterAsync(IPublicClientApplication app, CancellationToken ct = default); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/TokenCacheService.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/TokenCacheService.cs new file mode 100644 index 0000000..ecafc16 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/TokenCacheService.cs @@ -0,0 +1,47 @@ +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Extensions.Msal; +using MELogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace AStar.Dev.CloudSyncFunctional.Auth; + +/// +public sealed partial class TokenCacheService(ILogger logger) : ITokenCacheService +{ + private const string CacheFileName = "token_cache.bin3"; + private const string AppFolderName = "astar-cloudsync"; + + private static readonly string CacheDir = BuildCacheDir(); + + /// + public async Task RegisterAsync(IPublicClientApplication app, CancellationToken ct = default) + { + try + { + var properties = new StorageCreationPropertiesBuilder(CacheFileName, CacheDir).Build(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var helper = await MsalCacheHelper.CreateAsync(properties).WaitAsync(cts.Token).ConfigureAwait(false); + helper.RegisterCache(app.UserTokenCache); + } + catch (Exception ex) + { + LogCacheRegistrationFailed(logger, ex.Message); + } + } + + private static string BuildCacheDir() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return Path.Combine(home, "AppData", "Roaming", AppFolderName); + + return RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? Path.Combine(home, "Library", "Application Support", AppFolderName) + : Path.Combine(home, ".config", AppFolderName); + } + + [LoggerMessage(Level = MELogLevel.Warning, Message = "Token cache registration failed: {ErrorMessage}")] + private static partial void LogCacheRegistrationFailed(ILogger logger, string errorMessage); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Domain/OneDriveAccount.cs b/src/AStar.Dev.CloudSyncFunctional/Domain/OneDriveAccount.cs new file mode 100644 index 0000000..cf179c0 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Domain/OneDriveAccount.cs @@ -0,0 +1,22 @@ +using AStar.Dev.CloudSyncFunctional.Auth; + +namespace AStar.Dev.CloudSyncFunctional.Domain; + +/// Represents an authenticated OneDrive account and its sync configuration. +public sealed class OneDriveAccount +{ + /// Gets the MSAL HomeAccountId identifier. + public string AccountId { get; init; } = string.Empty; + + /// Gets the account's display name and email. + public AccountProfile Profile { get; init; } = new(string.Empty, string.Empty); + + /// Gets or sets whether this account is active for sync. + public bool IsActive { get; set; } + + /// Gets the Graph drive ID for this account's OneDrive. + public string? DriveId { get; init; } + + /// Gets the IDs of folders the user selected for sync. + public IReadOnlyList SelectedFolderIds { get; init; } = []; +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/DriveFolder.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/DriveFolder.cs new file mode 100644 index 0000000..739d076 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/DriveFolder.cs @@ -0,0 +1,7 @@ +namespace AStar.Dev.CloudSyncFunctional.Graph; + +/// A folder in a OneDrive drive. +/// The Graph drive item ID. +/// The folder display name. +/// The parent folder ID, or null for root-level folders. +public sealed record DriveFolder(string Id, string Name, string? ParentId); diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/GraphClientFactory.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphClientFactory.cs new file mode 100644 index 0000000..12a9079 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphClientFactory.cs @@ -0,0 +1,26 @@ +using Microsoft.Graph; +using Microsoft.Kiota.Abstractions.Authentication; + +namespace AStar.Dev.CloudSyncFunctional.Graph; + +/// +public sealed class GraphClientFactory : IGraphClientFactory +{ + /// + public GraphServiceClient CreateClient(string accessToken) => + new(new BaseBearerTokenAuthenticationProvider(new StaticAccessTokenProvider(accessToken))); + + private sealed class StaticAccessTokenProvider(string token) : IAccessTokenProvider + { + /// Returns the static bearer token for every request. + /// The request URI (unused). + /// Additional context (unused). + /// Token to cancel the operation. + /// The access token. + public Task GetAuthorizationTokenAsync(Uri uri, Dictionary? additionalAuthenticationContext = null, CancellationToken cancellationToken = default) => + Task.FromResult(token); + + /// + public AllowedHostsValidator AllowedHostsValidator { get; } = new(["graph.microsoft.com"]); + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/GraphError.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphError.cs new file mode 100644 index 0000000..3d1362a --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphError.cs @@ -0,0 +1,95 @@ +namespace AStar.Dev.CloudSyncFunctional.Graph; + +/// Base type for Graph API errors. +public abstract record GraphError +{ + /// Gets the human-readable error message. + public abstract string Message { get; } +} + +/// The requested item was not found in OneDrive. +public sealed record GraphNotFoundError : GraphError +{ + /// Gets the ID of the item that was not found. + public string ItemId { get; } + + /// + public override string Message => $"Item '{ItemId}' was not found in OneDrive."; + + /// Initialises a new . + /// The ID of the item that was not found. + public GraphNotFoundError(string itemId) => ItemId = itemId; +} + +/// The Graph API request was throttled. +public sealed record GraphThrottledError : GraphError +{ + /// Gets how long to wait before retrying, in seconds. + public int RetryAfterSeconds { get; } + + /// + public override string Message => $"Request throttled. Retry after {RetryAfterSeconds} seconds."; + + /// Initialises a new . + /// Seconds to wait before retrying. + public GraphThrottledError(int retryAfterSeconds) => RetryAfterSeconds = retryAfterSeconds; +} + +/// The request was rejected due to invalid or expired credentials. +public sealed record GraphUnauthorizedError : GraphError +{ + /// + public override string Message => "Unauthorized. Re-authentication required."; +} + +/// A network-level error occurred during a Graph API call. +public sealed record GraphNetworkError : GraphError +{ + /// + public override string Message { get; } + + /// Initialises a new . + /// The error message. + public GraphNetworkError(string message) => Message = message; +} + +/// An unexpected error occurred during a Graph API call. +public sealed record GraphUnexpectedError : GraphError +{ + /// + public override string Message { get; } + + /// Initialises a new . + /// The error message. + public GraphUnexpectedError(string message) => Message = message; +} + +/// Creates instances. +public static class GraphErrorFactory +{ + /// Creates a . + /// The ID of the item that was not found. + /// A not-found error. + public static GraphError NotFound(string itemId) => new GraphNotFoundError(itemId); + + /// Creates a . + /// Seconds to wait before retrying. + /// A throttled error. + public static GraphError Throttled(int retryAfterSeconds) => new GraphThrottledError(retryAfterSeconds); + + /// Creates a . + /// An unauthorized error. + public static GraphError Unauthorized() => new GraphUnauthorizedError(); + + /// Creates a . + /// The error message; falls back to a default if null or whitespace. + /// A network error. + public static GraphError Network(string? message) => new GraphNetworkError( + string.IsNullOrWhiteSpace(message) ? "A network error occurred: unknown error." : message); + + /// Creates a . + /// The error message; falls back to a default if null or whitespace. + /// An unexpected error. + public static GraphError Unexpected(string? message) => new GraphUnexpectedError( + string.IsNullOrWhiteSpace(message) ? "An unexpected Graph error occurred: unknown error." : message); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs new file mode 100644 index 0000000..1f41c2d --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs @@ -0,0 +1,12 @@ +using AStar.Dev.FunctionalParadigm; +using Microsoft.Extensions.Logging; + +namespace AStar.Dev.CloudSyncFunctional.Graph; + +/// +public sealed partial class GraphService(IGraphClientFactory clientFactory, ILogger logger) : IGraphService +{ + /// + public Task, GraphError>> GetRootFoldersAsync(string accountId, string accessToken, CancellationToken ct = default) + => throw new NotImplementedException(); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/IGraphClientFactory.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/IGraphClientFactory.cs new file mode 100644 index 0000000..bae0a82 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/IGraphClientFactory.cs @@ -0,0 +1,12 @@ +using Microsoft.Graph; + +namespace AStar.Dev.CloudSyncFunctional.Graph; + +/// Creates Graph SDK clients authenticated with a given access token. +public interface IGraphClientFactory +{ + /// Creates a new authenticated with the given bearer token. + /// The OAuth2 bearer token. + /// A new instance. + GraphServiceClient CreateClient(string accessToken); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/IGraphService.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/IGraphService.cs new file mode 100644 index 0000000..99f7465 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/IGraphService.cs @@ -0,0 +1,14 @@ +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Graph; + +/// Provides access to OneDrive via the Microsoft Graph API. +public interface IGraphService +{ + /// Returns the root-level folders for the given account's OneDrive. + /// The MSAL HomeAccountId identifier. + /// The OAuth2 bearer token for Graph API calls. + /// Token to cancel the operation. + /// A list of root folders, or a on failure. + Task, GraphError>> GetRootFoldersAsync(string accountId, string accessToken, CancellationToken ct = default); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/InternalsVisibleTo.cs b/src/AStar.Dev.CloudSyncFunctional/InternalsVisibleTo.cs new file mode 100644 index 0000000..6632474 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("AStar.Dev.CloudSyncFunctional.Tests.Unit")] diff --git a/src/AStar.Dev.CloudSyncFunctional/MainWindow.axaml b/src/AStar.Dev.CloudSyncFunctional/MainWindow.axaml index a2bfd0c..9b173d9 100644 --- a/src/AStar.Dev.CloudSyncFunctional/MainWindow.axaml +++ b/src/AStar.Dev.CloudSyncFunctional/MainWindow.axaml @@ -6,6 +6,7 @@ xmlns:controls="clr-namespace:AStar.Dev.CloudSyncFunctional.Controls" xmlns:accounts="clr-namespace:AStar.Dev.CloudSyncFunctional.Accounts" xmlns:folderTree="clr-namespace:AStar.Dev.CloudSyncFunctional.FolderTree" + xmlns:wizard="clr-namespace:AStar.Dev.CloudSyncFunctional.Wizard" xmlns:lucide="clr-namespace:Lucide.Avalonia;assembly=Lucide.Avalonia" mc:Ignorable="d" d:DesignWidth="1280" d:DesignHeight="820" x:Class="AStar.Dev.CloudSyncFunctional.MainWindow" @@ -54,7 +55,7 @@ - + @@ -181,11 +182,15 @@ - + Background="Transparent" + Cursor="Hand" Margin="14,8" + Padding="0" + HorizontalContentAlignment="Stretch"> @@ -197,7 +202,7 @@ Foreground="{DynamicResource Ink3}" VerticalAlignment="Center"/> - + + + + + + + + + + diff --git a/src/AStar.Dev.CloudSyncFunctional/MainWindow.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/MainWindow.axaml.cs index 2b2a3d9..a0383e5 100644 --- a/src/AStar.Dev.CloudSyncFunctional/MainWindow.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/MainWindow.axaml.cs @@ -7,14 +7,20 @@ namespace AStar.Dev.CloudSyncFunctional; /// The main application window containing the titlebar, sidebar, and main pane. public partial class MainWindow : Window { - /// Initializes the main window, sets the workspace as the data context, and wires chrome controls. - public MainWindow() + /// Initializes the main window with the provided workspace ViewModel. + /// The workspace ViewModel resolved from DI. + public MainWindow(WorkspaceViewModel viewModel) { InitializeComponent(); - DataContext = new WorkspaceViewModel(); + DataContext = viewModel; WireChrome(); } + /// Initializes the main window with a default design-time workspace ViewModel. + public MainWindow() : this(new WorkspaceViewModel()) + { + } + private void WireChrome() { TitleBarGrid.AddHandler(PointerPressedEvent, OnTitleBarPressed); diff --git a/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs b/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs new file mode 100644 index 0000000..d7463b1 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs @@ -0,0 +1,13 @@ +using AStar.Dev.CloudSyncFunctional.Domain; +using AStar.Dev.FunctionalParadigm; +using Microsoft.Extensions.Logging; + +namespace AStar.Dev.CloudSyncFunctional.Onboarding; + +/// +public sealed partial class AccountOnboardingService(ILogger logger) : IAccountOnboardingService +{ + /// + public Task> CompleteOnboardingAsync(OneDriveAccount account, CancellationToken ct = default) + => throw new NotImplementedException(); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Onboarding/IAccountOnboardingService.cs b/src/AStar.Dev.CloudSyncFunctional/Onboarding/IAccountOnboardingService.cs new file mode 100644 index 0000000..b863ec4 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Onboarding/IAccountOnboardingService.cs @@ -0,0 +1,14 @@ +using AStar.Dev.CloudSyncFunctional.Domain; +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Onboarding; + +/// Persists a new account and its initial sync configuration after the wizard completes. +public interface IAccountOnboardingService +{ + /// Completes account onboarding and returns the finalised account. + /// The account to onboard. + /// Token to cancel the operation. + /// The finalised on success, or a on failure. + Task> CompleteOnboardingAsync(OneDriveAccount account, CancellationToken ct = default); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Onboarding/PersistenceError.cs b/src/AStar.Dev.CloudSyncFunctional/Onboarding/PersistenceError.cs new file mode 100644 index 0000000..057cd7a --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Onboarding/PersistenceError.cs @@ -0,0 +1,19 @@ +namespace AStar.Dev.CloudSyncFunctional.Onboarding; + +/// Base type for persistence errors. +public abstract record PersistenceError +{ + /// Gets the human-readable error message. + public abstract string Message { get; } +} + +/// An unexpected error occurred during a persistence operation. +public sealed record PersistenceUnexpectedError : PersistenceError +{ + /// + public override string Message { get; } + + /// Initialises a new with the given message. + /// The error message. + public PersistenceUnexpectedError(string message) => Message = message; +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml b/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml new file mode 100644 index 0000000..e545f60 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml.cs new file mode 100644 index 0000000..ee74288 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml.cs @@ -0,0 +1,10 @@ +using Avalonia.Controls; + +namespace AStar.Dev.CloudSyncFunctional.Wizard; + +/// Code-behind for the add-account wizard view. +public partial class AddAccountWizardView : UserControl +{ + /// Initializes a new instance of . + public AddAccountWizardView() => InitializeComponent(); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardViewModel.cs b/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardViewModel.cs new file mode 100644 index 0000000..4afff18 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardViewModel.cs @@ -0,0 +1,152 @@ +using System.Collections.ObjectModel; +using System.Reactive.Disposables; +using AStar.Dev.CloudSyncFunctional.Accounts; +using AStar.Dev.CloudSyncFunctional.Auth; +using AStar.Dev.CloudSyncFunctional.Domain; +using AStar.Dev.CloudSyncFunctional.Graph; +using AStar.Dev.CloudSyncFunctional.Onboarding; +using ReactiveUI; +using RxUnit = System.Reactive.Unit; + +namespace AStar.Dev.CloudSyncFunctional.Wizard; + +/// ViewModel for the multi-step add-account wizard. +public sealed class AddAccountWizardViewModel : ReactiveObject, IDisposable +{ + private readonly CompositeDisposable _disposables = new(); + + /// Gets or sets the current wizard step. + public WizardStep CurrentStep + { + get; + set + { + this.RaiseAndSetIfChanged(ref field, value); + this.RaisePropertyChanged(nameof(IsProviderSelectionStep)); + this.RaisePropertyChanged(nameof(IsSignInStep)); + this.RaisePropertyChanged(nameof(IsSelectFoldersStep)); + this.RaisePropertyChanged(nameof(CanGoBack)); + } + } = WizardStep.ProviderSelection; + + /// Gets whether the wizard is on the provider-selection step. + public bool IsProviderSelectionStep => CurrentStep == WizardStep.ProviderSelection; + + /// Gets whether the wizard is on the sign-in step. + public bool IsSignInStep => CurrentStep == WizardStep.SignIn; + + /// Gets whether the wizard is on the folder-selection step. + public bool IsSelectFoldersStep => CurrentStep == WizardStep.SelectFolders; + + /// Gets whether the Back button should be enabled. + public bool CanGoBack => CurrentStep != WizardStep.ProviderSelection; + + /// Gets or sets whether the user has successfully signed in. + public bool IsSignedIn + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } + + /// Gets or sets whether the authentication flow is in progress. + public bool IsWaitingForAuth + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } + + /// Gets or sets the status text displayed on the sign-in step. + public string SignInStatusText + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = string.Empty; + + /// Gets or sets whether the sign-in step has an error to display. + public bool SignInHasError + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } + + /// Gets or sets the "not implemented" message shown for Google Drive and Dropbox. + public string NotImplementedMessage + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = string.Empty; + + /// Gets or sets whether the "not implemented" message should be visible. + public bool ShowNotImplemented + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } + + /// Gets or sets whether the folder list is currently loading. + public bool IsLoadingFolders + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } + + /// Gets or sets whether the wizard has a general error to display. + public bool HasError + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } + + /// Gets or sets the general wizard error message. + public string ErrorMessage + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = string.Empty; + + /// Gets the available OneDrive folders for the user to select. + public ObservableCollection Folders { get; } = []; + + /// Gets the command that selects a cloud provider and advances or shows a message. + public ReactiveCommand SelectProvider { get; } + + /// Gets the command that starts the MSAL interactive sign-in flow. + public ReactiveCommand SignIn { get; } + + /// Gets the command that navigates back to the previous wizard step. + public ReactiveCommand Back { get; } + + /// Gets the command that finalises account onboarding and raises . + public ReactiveCommand AddAccount { get; } + + /// Gets the command that cancels the wizard and raises . + public ReactiveCommand Cancel { get; } + + /// Raised when account onboarding completes successfully. + public event EventHandler? Completed; + + /// Raised when the user cancels the wizard. + public event EventHandler? Cancelled; + + /// Initialises a new . + /// The authentication service for MSAL sign-in. + /// The Graph service for fetching OneDrive folders. + /// The service that persists the completed account. + public AddAccountWizardViewModel(IAuthService authService, IGraphService graphService, IAccountOnboardingService onboardingService) + { + var canSignIn = this.WhenAnyValue(x => x.IsWaitingForAuth, waiting => !waiting); + + SelectProvider = ReactiveCommand.CreateFromTask((kind, ct) => Task.FromResult(RxUnit.Default)); + SignIn = ReactiveCommand.CreateFromTask(ct => Task.CompletedTask, canSignIn); + Back = ReactiveCommand.Create(() => { }); + AddAccount = ReactiveCommand.CreateFromTask(ct => Task.CompletedTask); + Cancel = ReactiveCommand.CreateFromTask(ct => Task.CompletedTask); + } + + /// + public void Dispose() => _disposables.Dispose(); + + /// Test helper that raises the event directly. + /// The account to pass with the event. + internal void SimulateCompleted(OneDriveAccount account) => Completed?.Invoke(this, account); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Wizard/WizardFolderItem.cs b/src/AStar.Dev.CloudSyncFunctional/Wizard/WizardFolderItem.cs new file mode 100644 index 0000000..4f6f800 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Wizard/WizardFolderItem.cs @@ -0,0 +1,20 @@ +using ReactiveUI; + +namespace AStar.Dev.CloudSyncFunctional.Wizard; + +/// Represents a OneDrive root folder shown in the wizard's folder-selection step. +public sealed class WizardFolderItem : ReactiveObject +{ + /// Gets the Graph drive item ID. + public string FolderId { get; init; } = string.Empty; + + /// Gets the folder display name. + public string Name { get; init; } = string.Empty; + + /// Gets or sets whether this folder is selected for sync. + public bool IsSelected + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Wizard/WizardStep.cs b/src/AStar.Dev.CloudSyncFunctional/Wizard/WizardStep.cs new file mode 100644 index 0000000..8967006 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Wizard/WizardStep.cs @@ -0,0 +1,14 @@ +namespace AStar.Dev.CloudSyncFunctional.Wizard; + +/// Identifies the current step in the add-account wizard. +public enum WizardStep +{ + /// The user selects a cloud storage provider. + ProviderSelection, + + /// The user signs in with their chosen provider. + SignIn, + + /// The user selects which folders to sync. + SelectFolders +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs b/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs index 5770747..43e6989 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs @@ -1,15 +1,19 @@ -using System; using System.Collections.ObjectModel; -using System.Linq; using AStar.Dev.CloudSyncFunctional.Accounts; +using AStar.Dev.CloudSyncFunctional.Domain; using AStar.Dev.CloudSyncFunctional.FolderTree; +using AStar.Dev.CloudSyncFunctional.Wizard; +using Microsoft.Extensions.DependencyInjection; using ReactiveUI; +using RxUnit = System.Reactive.Unit; namespace AStar.Dev.CloudSyncFunctional.Workspace; /// Root view-model for the application workspace. Holds all accounts and summary statistics. public class WorkspaceViewModel : ReactiveObject { + private readonly IServiceProvider _serviceProvider; + /// Gets all cloud storage accounts registered in the workspace. public ObservableCollection Accounts { get; } = BuildAccounts(); @@ -25,6 +29,16 @@ public AccountViewModel? SelectedAccount } } + /// Gets or sets the current overlay content (e.g. the add-account wizard). Null means no overlay. + public ReactiveObject? CurrentOverlay + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } + + /// Gets the command that opens the add-account wizard overlay. + public ReactiveCommand OpenAddAccountWizard { get; } + /// Gets hourly transfer buckets for today (24 values, index = hour). public int[] TodayBuckets { get; } = [ @@ -57,10 +71,18 @@ public AccountViewModel? SelectedAccount /// Gets a formatted subtitle summarising account count and total storage capacity. public string WorkspaceSubtitle => $"{Accounts.Count} accounts · {Accounts.Sum(a => a.TotalBytes) / 1_099_511_627_776.0:F1} TB total"; - /// Initialises a new and selects the first account. - public WorkspaceViewModel() + /// Initialises a new using the provided service provider. + /// The DI container used to resolve the wizard ViewModel on demand. + public WorkspaceViewModel(IServiceProvider serviceProvider) { + _serviceProvider = serviceProvider; SelectedAccount = Accounts[0]; + OpenAddAccountWizard = ReactiveCommand.Create(() => { }); // stub: does not set CurrentOverlay + } + + /// Initialises a new with no DI services (design-time and test use). + public WorkspaceViewModel() : this(EmptyServiceProvider.Instance) + { } private static ObservableCollection BuildAccounts() => @@ -176,4 +198,14 @@ private static ObservableCollection BuildWorkFolderTree() return [documentsNode, engineeringNode]; } + + private sealed class EmptyServiceProvider : IServiceProvider + { + public static EmptyServiceProvider Instance { get; } = new(); + + /// Returns null for all service types. + /// The requested service type. + /// Always null. + public object? GetService(Type serviceType) => null; + } } diff --git a/src/AStar.Dev.FunctionalParadigm/ResultExtensions.cs b/src/AStar.Dev.FunctionalParadigm/ResultExtensions.cs index ccbe15a..8a2c233 100644 --- a/src/AStar.Dev.FunctionalParadigm/ResultExtensions.cs +++ b/src/AStar.Dev.FunctionalParadigm/ResultExtensions.cs @@ -1,11 +1,16 @@ namespace AStar.Dev.FunctionalParadigm; +/// Extension methods for . public static class ResultExtensions { - public static Result Tap( - this Result result, - Action onSuccess, - Action? onFailure = null) + /// Applies a side-effect to the current result without changing it. + /// The success value type. + /// The error type. + /// The result to tap. + /// Action to invoke on success. + /// Optional action to invoke on failure. + /// The original result unchanged. + public static Result Tap(this Result result, Action onSuccess, Action? onFailure = null) { switch (result) { @@ -22,9 +27,14 @@ public static Result Tap( } } - public static Result Map( - this Result result, - Func selector) + /// Transforms the success value using the provided selector. + /// The input success type. + /// The output success type. + /// The error type. + /// The result to map. + /// The transformation function. + /// A new result with the transformed value, or the original failure. + public static Result Map(this Result result, Func selector) { return result switch { @@ -34,9 +44,14 @@ public static Result Map( }; } - public static Result Bind( - this Result result, - Func> binder) + /// Chains an operation that also returns a result, propagating failure. + /// The input success type. + /// The output success type. + /// The error type. + /// The result to bind. + /// The chained operation. + /// The result of the chained operation, or the original failure. + public static Result Bind(this Result result, Func> binder) { return result switch { @@ -46,10 +61,15 @@ public static Result Bind( }; } - public static TOut Match( - this Result result, - Func onSuccess, - Func onFailure) + /// Collapses the result to a single output value by handling both cases. + /// The success value type. + /// The error type. + /// The output type. + /// The result to match. + /// Function invoked on success. + /// Function invoked on failure. + /// The value produced by whichever branch was taken. + public static TOut Match(this Result result, Func onSuccess, Func onFailure) { return result switch { @@ -58,4 +78,31 @@ public static TOut Match( _ => throw new InvalidOperationException("Unexpected result type.") }; } + + /// Asynchronously handles both result cases as side-effects. + /// The success value type. + /// The error type. + /// The task producing the result. + /// Action invoked on success. + /// Action invoked on failure. + /// A task that completes after the appropriate action runs. + public static async Task MatchAsync(this Task> taskResult, Action onSuccess, Action onFailure) + { + _ = await taskResult.ConfigureAwait(false); // stub: awaits but never calls callbacks + } + + /// Asynchronously collapses the result to a single output value by handling both cases. + /// The success value type. + /// The error type. + /// The output type. + /// The task producing the result. + /// Function invoked on success. + /// Function invoked on failure. + /// A task that produces the value from whichever branch was taken. + public static async Task MatchAsync(this Task> taskResult, Func onSuccess, Func onFailure) + { + _ = await taskResult.ConfigureAwait(false); // stub: never calls functions + + return default!; + } } diff --git a/src/AStar.Dev.FunctionalParadigm/Unit.cs b/src/AStar.Dev.FunctionalParadigm/Unit.cs new file mode 100644 index 0000000..c121b45 --- /dev/null +++ b/src/AStar.Dev.FunctionalParadigm/Unit.cs @@ -0,0 +1,8 @@ +namespace AStar.Dev.FunctionalParadigm; + +/// Represents the absence of a meaningful return value. +public record Unit +{ + /// Gets the singleton default instance. + public static Unit Default { get; } = new(); +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/AStar.Dev.CloudSyncFunctional.Tests.Unit.csproj b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/AStar.Dev.CloudSyncFunctional.Tests.Unit.csproj index fa88607..3369c34 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/AStar.Dev.CloudSyncFunctional.Tests.Unit.csproj +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/AStar.Dev.CloudSyncFunctional.Tests.Unit.csproj @@ -8,7 +8,7 @@ net10.0 true true - NU1902;CA1859 + NU1902;NU1903;CA1859 @@ -18,11 +18,16 @@ + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthError.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthError.cs new file mode 100644 index 0000000..bc6fa6a --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthError.cs @@ -0,0 +1,55 @@ +using AStar.Dev.CloudSyncFunctional.Auth; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.Auth; + +public class GivenAnAuthError +{ + [Fact] + public void when_cancelled_is_called_then_result_is_auth_cancelled_error() + { + var error = AuthErrorFactory.Cancelled(); + + error.ShouldBeOfType(); + } + + [Fact] + public void when_cancelled_is_called_then_message_is_not_empty() + { + var error = AuthErrorFactory.Cancelled(); + + error.Message.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public void when_failed_is_called_with_message_then_result_is_auth_failed_error() + { + var error = AuthErrorFactory.Failed("MSAL error"); + + error.ShouldBeOfType(); + } + + [Fact] + public void when_failed_is_called_with_message_then_message_is_preserved() + { + var error = AuthErrorFactory.Failed("MSAL error"); + + error.Message.ShouldBe("MSAL error"); + } + + [Fact] + public void when_failed_is_called_with_null_message_then_default_message_is_used() + { + var error = AuthErrorFactory.Failed(null); + + error.Message.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public void when_failed_is_called_with_whitespace_message_then_default_message_is_used() + { + var error = AuthErrorFactory.Failed(" "); + + error.Message.ShouldNotBeNullOrEmpty(); + error.Message.ShouldNotBe(" "); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthResult.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthResult.cs new file mode 100644 index 0000000..fb046b4 --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthResult.cs @@ -0,0 +1,59 @@ +using AStar.Dev.CloudSyncFunctional.Auth; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.Auth; + +public class GivenAnAuthResult +{ + private static readonly AccountProfile TestProfile = new("Test User", "test@example.com"); + private static readonly DateTimeOffset TestExpiry = DateTimeOffset.UtcNow.AddHours(1); + + [Fact] + public void when_create_is_called_then_access_token_is_set() + { + var result = AuthResultFactory.Create("token123", "account-id", TestProfile, TestExpiry); + + result.AccessToken.ShouldBe("token123"); + } + + [Fact] + public void when_create_is_called_then_account_id_is_set() + { + var result = AuthResultFactory.Create("token", "account-id-abc", TestProfile, TestExpiry); + + result.AccountId.ShouldBe("account-id-abc"); + } + + [Fact] + public void when_create_is_called_then_profile_is_set() + { + var result = AuthResultFactory.Create("token", "id", TestProfile, TestExpiry); + + result.Profile.ShouldBeSameAs(TestProfile); + } + + [Fact] + public void when_create_is_called_then_expires_on_is_set() + { + var result = AuthResultFactory.Create("token", "id", TestProfile, TestExpiry); + + result.ExpiresOn.ShouldBe(TestExpiry); + } + + [Fact] + public void when_create_is_called_with_empty_access_token_then_throws() + { + Should.Throw(() => AuthResultFactory.Create("", "id", TestProfile, TestExpiry)); + } + + [Fact] + public void when_create_is_called_with_empty_account_id_then_throws() + { + Should.Throw(() => AuthResultFactory.Create("token", "", TestProfile, TestExpiry)); + } + + [Fact] + public void when_create_is_called_with_null_profile_then_throws() + { + Should.Throw(() => AuthResultFactory.Create("token", "id", null!, TestExpiry)); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/FunctionalParadigm/GivenAUnit.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/FunctionalParadigm/GivenAUnit.cs new file mode 100644 index 0000000..c856b5a --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/FunctionalParadigm/GivenAUnit.cs @@ -0,0 +1,27 @@ +using FpUnit = AStar.Dev.FunctionalParadigm.Unit; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.FunctionalParadigm; + +public class GivenAUnit +{ + [Fact] + public void when_default_is_accessed_then_returns_non_null_instance() + { + FpUnit.Default.ShouldNotBeNull(); + } + + [Fact] + public void when_default_is_accessed_twice_then_same_reference_is_returned() + { + FpUnit.Default.ShouldBeSameAs(FpUnit.Default); + } + + [Fact] + public void when_two_unit_instances_are_compared_then_they_are_equal() + { + var a = new FpUnit(); + var b = new FpUnit(); + + a.ShouldBe(b); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/FunctionalParadigm/GivenMatchAsync.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/FunctionalParadigm/GivenMatchAsync.cs new file mode 100644 index 0000000..8355b74 --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/FunctionalParadigm/GivenMatchAsync.cs @@ -0,0 +1,108 @@ +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.FunctionalParadigm; + +public class GivenMatchAsync +{ + [Fact] + public async Task when_task_result_is_ok_then_success_action_is_called() + { + var called = false; + Task> task = Task.FromResult>(new Ok(42)); + + await task.MatchAsync( + ok => { called = true; }, + _ => { }); + + called.ShouldBeTrue(); + } + + [Fact] + public async Task when_task_result_is_fail_then_failure_action_is_called() + { + var called = false; + Task> task = Task.FromResult>(new Fail("err")); + + await task.MatchAsync( + _ => { }, + err => { called = true; }); + + called.ShouldBeTrue(); + } + + [Fact] + public async Task when_task_result_is_ok_then_success_action_receives_correct_value() + { + int received = 0; + Task> task = Task.FromResult>(new Ok(42)); + + await task.MatchAsync( + ok => { received = ok; }, + _ => { }); + + received.ShouldBe(42); + } + + [Fact] + public async Task when_task_result_is_fail_then_failure_action_receives_correct_error() + { + string? received = null; + Task> task = Task.FromResult>(new Fail("boom")); + + await task.MatchAsync( + _ => { }, + err => { received = err; }); + + received.ShouldBe("boom"); + } + + [Fact] + public async Task when_task_result_is_ok_then_failure_branch_is_not_called() + { + var failureCalled = false; + Task> task = Task.FromResult>(new Ok(1)); + + await task.MatchAsync( + _ => { }, + _ => { failureCalled = true; }); + + failureCalled.ShouldBeFalse(); + } + + [Fact] + public async Task when_task_result_is_fail_then_success_action_is_not_called() + { + var successCalled = false; + Task> task = Task.FromResult>(new Fail("err")); + + await task.MatchAsync( + _ => { successCalled = true; }, + _ => { }); + + successCalled.ShouldBeFalse(); + } + + [Fact] + public async Task when_task_result_is_ok_then_func_overload_returns_mapped_value() + { + Task> task = Task.FromResult>(new Ok(5)); + + var result = await task.MatchAsync( + ok => ok * 2, + _ => -1); + + result.ShouldBe(10); + } + + [Fact] + public async Task when_task_result_is_fail_then_func_overload_returns_failure_value() + { + Task> task = Task.FromResult>(new Fail("err")); + + var result = await task.MatchAsync( + ok => ok * 2, + _ => -1); + + result.ShouldBe(-1); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Graph/GivenAGraphError.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Graph/GivenAGraphError.cs new file mode 100644 index 0000000..74dadc2 --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Graph/GivenAGraphError.cs @@ -0,0 +1,62 @@ +using AStar.Dev.CloudSyncFunctional.Graph; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.Graph; + +public class GivenAGraphError +{ + [Fact] + public void when_not_found_is_called_then_result_is_graph_not_found_error() + { + var error = GraphErrorFactory.NotFound("item-123"); + + error.ShouldBeOfType(); + } + + [Fact] + public void when_not_found_is_called_then_item_id_appears_in_message() + { + var error = GraphErrorFactory.NotFound("item-123"); + + error.Message.ShouldContain("item-123"); + } + + [Fact] + public void when_network_is_called_with_null_message_then_default_message_is_used() + { + var error = GraphErrorFactory.Network(null); + + error.Message.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public void when_network_is_called_with_message_then_message_is_preserved() + { + var error = GraphErrorFactory.Network("connection refused"); + + error.Message.ShouldBe("connection refused"); + } + + [Fact] + public void when_unexpected_is_called_with_null_then_default_message_is_used() + { + var error = GraphErrorFactory.Unexpected(null); + + error.Message.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public void when_throttled_is_called_then_retry_seconds_appear_in_message() + { + var error = GraphErrorFactory.Throttled(30); + + error.Message.ShouldContain("30"); + } + + [Fact] + public void when_unauthorized_is_called_then_message_is_not_empty() + { + var error = GraphErrorFactory.Unauthorized(); + + error.Message.ShouldNotBeNullOrEmpty(); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Infrastructure/ReactiveUiFixture.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Infrastructure/ReactiveUiFixture.cs new file mode 100644 index 0000000..eb9e487 --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Infrastructure/ReactiveUiFixture.cs @@ -0,0 +1,14 @@ +using ReactiveUI; +using ReactiveUI.Builder; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.Infrastructure; + +public sealed class ReactiveUiFixture +{ + static ReactiveUiFixture() + { + RxAppBuilder.CreateReactiveUIBuilder() + .WithCoreServices() + .BuildApp(); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Onboarding/GivenAnAccountOnboardingService.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Onboarding/GivenAnAccountOnboardingService.cs new file mode 100644 index 0000000..7eb0498 --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Onboarding/GivenAnAccountOnboardingService.cs @@ -0,0 +1,68 @@ +using AStar.Dev.CloudSyncFunctional.Auth; +using AStar.Dev.CloudSyncFunctional.Domain; +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.FunctionalParadigm; +using Microsoft.Extensions.Logging; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.Onboarding; + +public class GivenAnAccountOnboardingService +{ + private static AccountOnboardingService CreateSut() => + new(Substitute.For>()); + + private static OneDriveAccount CreateAccount() => + new() + { + AccountId = "test-account-id", + Profile = new AccountProfile("Test User", "test@example.com"), + SelectedFolderIds = ["folder-1", "folder-2"] + }; + + [Fact] + public async Task when_complete_onboarding_is_called_then_result_is_ok() + { + var sut = CreateSut(); + var account = CreateAccount(); + + var result = await sut.CompleteOnboardingAsync(account, CancellationToken.None); + + result.ShouldBeOfType>(); + } + + [Fact] + public async Task when_complete_onboarding_is_called_then_returned_account_has_same_id() + { + var sut = CreateSut(); + var account = CreateAccount(); + + var result = await sut.CompleteOnboardingAsync(account, CancellationToken.None); + + var ok = (Ok)result; + ok.Value.AccountId.ShouldBe("test-account-id"); + } + + [Fact] + public async Task when_complete_onboarding_is_called_then_account_is_active() + { + var sut = CreateSut(); + var account = CreateAccount(); + + var result = await sut.CompleteOnboardingAsync(account, CancellationToken.None); + + var ok = (Ok)result; + ok.Value.IsActive.ShouldBeTrue(); + } + + [Fact] + public async Task when_complete_onboarding_is_called_then_profile_is_preserved() + { + var sut = CreateSut(); + var account = CreateAccount(); + + var result = await sut.CompleteOnboardingAsync(account, CancellationToken.None); + + var ok = (Ok)result; + ok.Value.Profile.Email.ShouldBe("test@example.com"); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Wizard/GivenAnAddAccountWizardViewModel.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Wizard/GivenAnAddAccountWizardViewModel.cs new file mode 100644 index 0000000..6a3b27a --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Wizard/GivenAnAddAccountWizardViewModel.cs @@ -0,0 +1,440 @@ +using AStar.Dev.CloudSyncFunctional.Accounts; +using AStar.Dev.CloudSyncFunctional.Auth; +using AStar.Dev.CloudSyncFunctional.Domain; +using AStar.Dev.CloudSyncFunctional.Graph; +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Tests.Unit.Infrastructure; +using AStar.Dev.CloudSyncFunctional.Wizard; +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.Wizard; + +public class GivenAnAddAccountWizardViewModel : IClassFixture +{ + private static AddAccountWizardViewModel CreateSut(IAuthService? auth = null, IGraphService? graph = null, IAccountOnboardingService? onboarding = null) + { + auth ??= Substitute.For(); + graph ??= Substitute.For(); + onboarding ??= Substitute.For(); + + return new AddAccountWizardViewModel(auth, graph, onboarding); + } + + [Fact] + public void when_constructed_then_current_step_is_provider_selection() + { + var sut = CreateSut(); + + sut.CurrentStep.ShouldBe(WizardStep.ProviderSelection); + } + + [Fact] + public void when_constructed_then_is_provider_selection_step_is_true() + { + var sut = CreateSut(); + + sut.IsProviderSelectionStep.ShouldBeTrue(); + } + + [Fact] + public void when_constructed_then_can_go_back_is_false() + { + var sut = CreateSut(); + + sut.CanGoBack.ShouldBeFalse(); + } + + [Fact] + public void when_constructed_then_is_signed_in_is_false() + { + var sut = CreateSut(); + + sut.IsSignedIn.ShouldBeFalse(); + } + + [Fact] + public void when_constructed_then_show_not_implemented_is_false() + { + var sut = CreateSut(); + + sut.ShowNotImplemented.ShouldBeFalse(); + } + + [Fact] + public void when_constructed_then_folders_is_empty() + { + var sut = CreateSut(); + + sut.Folders.ShouldBeEmpty(); + } + + [Fact] + public async Task when_select_provider_one_drive_then_step_is_sign_in() + { + var sut = CreateSut(); + + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + sut.CurrentStep.ShouldBe(WizardStep.SignIn); + } + + [Fact] + public async Task when_select_provider_one_drive_then_can_go_back_is_true() + { + var sut = CreateSut(); + + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + sut.CanGoBack.ShouldBeTrue(); + } + + [Fact] + public async Task when_select_provider_one_drive_then_show_not_implemented_is_false() + { + var sut = CreateSut(); + + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + sut.ShowNotImplemented.ShouldBeFalse(); + } + + [Fact] + public async Task when_select_provider_google_drive_then_step_stays_at_provider_selection() + { + var sut = CreateSut(); + + await sut.SelectProvider.Execute(ProviderKind.GoogleDrive); + + sut.CurrentStep.ShouldBe(WizardStep.ProviderSelection); + } + + [Fact] + public async Task when_select_provider_google_drive_then_shows_not_implemented_message() + { + var sut = CreateSut(); + + await sut.SelectProvider.Execute(ProviderKind.GoogleDrive); + + sut.ShowNotImplemented.ShouldBeTrue(); + sut.NotImplementedMessage.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public async Task when_select_provider_dropbox_then_step_stays_at_provider_selection() + { + var sut = CreateSut(); + + await sut.SelectProvider.Execute(ProviderKind.Dropbox); + + sut.CurrentStep.ShouldBe(WizardStep.ProviderSelection); + } + + [Fact] + public async Task when_select_provider_dropbox_then_shows_not_implemented_message() + { + var sut = CreateSut(); + + await sut.SelectProvider.Execute(ProviderKind.Dropbox); + + sut.ShowNotImplemented.ShouldBeTrue(); + sut.NotImplementedMessage.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public async Task when_select_provider_one_drive_then_not_implemented_is_hidden() + { + var sut = CreateSut(); + await sut.SelectProvider.Execute(ProviderKind.GoogleDrive); + + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + sut.ShowNotImplemented.ShouldBeFalse(); + } + + [Fact] + public async Task when_select_provider_then_not_implemented_message_is_cleared_on_second_one_drive_select() + { + var sut = CreateSut(); + await sut.SelectProvider.Execute(ProviderKind.GoogleDrive); + await sut.Back.Execute(); + + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + sut.ShowNotImplemented.ShouldBeFalse(); + } + + [Fact] + public async Task when_sign_in_succeeds_then_step_is_select_folders() + { + var auth = Substitute.For(); + var profile = new AccountProfile("Test User", "test@example.com"); + var authResult = AuthResultFactory.Create("token", "account-id", profile, DateTimeOffset.UtcNow.AddHours(1)); + auth.SignInInteractiveAsync(Arg.Any()) + .Returns(Task.FromResult>(new Ok(authResult))); + + var graph = Substitute.For(); + graph.GetRootFoldersAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult, GraphError>>(new Ok, GraphError>([new DriveFolder("id1", "Documents", null)]))); + + var sut = CreateSut(auth: auth, graph: graph); + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + await sut.SignIn.Execute(); + + sut.CurrentStep.ShouldBe(WizardStep.SelectFolders); + } + + [Fact] + public async Task when_sign_in_succeeds_then_is_signed_in_is_true() + { + var auth = Substitute.For(); + var profile = new AccountProfile("Test User", "test@example.com"); + var authResult = AuthResultFactory.Create("token", "account-id", profile, DateTimeOffset.UtcNow.AddHours(1)); + auth.SignInInteractiveAsync(Arg.Any()) + .Returns(Task.FromResult>(new Ok(authResult))); + + var graph = Substitute.For(); + graph.GetRootFoldersAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult, GraphError>>(new Ok, GraphError>([]))); + + var sut = CreateSut(auth: auth, graph: graph); + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + await sut.SignIn.Execute(); + + sut.IsSignedIn.ShouldBeTrue(); + } + + [Fact] + public async Task when_sign_in_succeeds_then_waiting_for_auth_is_false() + { + var auth = Substitute.For(); + var profile = new AccountProfile("Test", "test@example.com"); + var authResult = AuthResultFactory.Create("token", "id", profile, DateTimeOffset.UtcNow.AddHours(1)); + auth.SignInInteractiveAsync(Arg.Any()) + .Returns(Task.FromResult>(new Ok(authResult))); + + var graph = Substitute.For(); + graph.GetRootFoldersAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult, GraphError>>(new Ok, GraphError>([]))); + + var sut = CreateSut(auth: auth, graph: graph); + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + await sut.SignIn.Execute(); + + sut.IsWaitingForAuth.ShouldBeFalse(); + } + + [Fact] + public async Task when_sign_in_succeeds_then_status_text_shows_email() + { + var auth = Substitute.For(); + var profile = new AccountProfile("Test User", "test@example.com"); + var authResult = AuthResultFactory.Create("token", "account-id", profile, DateTimeOffset.UtcNow.AddHours(1)); + auth.SignInInteractiveAsync(Arg.Any()) + .Returns(Task.FromResult>(new Ok(authResult))); + + var graph = Substitute.For(); + graph.GetRootFoldersAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult, GraphError>>(new Ok, GraphError>([]))); + + var sut = CreateSut(auth: auth, graph: graph); + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + await sut.SignIn.Execute(); + + sut.SignInStatusText.ShouldContain("test@example.com"); + } + + [Fact] + public async Task when_sign_in_cancelled_then_step_is_provider_selection() + { + var auth = Substitute.For(); + auth.SignInInteractiveAsync(Arg.Any()) + .Returns(Task.FromResult>(new Fail(AuthErrorFactory.Cancelled()))); + + var sut = CreateSut(auth: auth); + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + await sut.SignIn.Execute(); + + sut.CurrentStep.ShouldBe(WizardStep.ProviderSelection); + } + + [Fact] + public async Task when_sign_in_cancelled_then_sign_in_has_no_error() + { + var auth = Substitute.For(); + auth.SignInInteractiveAsync(Arg.Any()) + .Returns(Task.FromResult>(new Fail(AuthErrorFactory.Cancelled()))); + + var sut = CreateSut(auth: auth); + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + await sut.SignIn.Execute(); + + sut.SignInHasError.ShouldBeFalse(); + } + + [Fact] + public async Task when_sign_in_fails_then_error_is_shown() + { + var auth = Substitute.For(); + auth.SignInInteractiveAsync(Arg.Any()) + .Returns(Task.FromResult>(new Fail(AuthErrorFactory.Failed("MSAL error")))); + + var sut = CreateSut(auth: auth); + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + await sut.SignIn.Execute(); + + sut.SignInHasError.ShouldBeTrue(); + sut.SignInStatusText.ShouldContain("MSAL error"); + } + + [Fact] + public async Task when_sign_in_fails_then_step_stays_at_sign_in() + { + var auth = Substitute.For(); + auth.SignInInteractiveAsync(Arg.Any()) + .Returns(Task.FromResult>(new Fail(AuthErrorFactory.Failed("error")))); + + var sut = CreateSut(auth: auth); + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + await sut.SignIn.Execute(); + + sut.CurrentStep.ShouldBe(WizardStep.SignIn); + } + + [Fact] + public async Task when_back_on_sign_in_step_then_step_is_provider_selection() + { + var sut = CreateSut(); + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + await sut.Back.Execute(); + + sut.CurrentStep.ShouldBe(WizardStep.ProviderSelection); + } + + [Fact] + public async Task when_back_on_sign_in_step_then_can_go_back_is_false() + { + var sut = CreateSut(); + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + + await sut.Back.Execute(); + + sut.CanGoBack.ShouldBeFalse(); + } + + [Fact] + public async Task when_back_on_sign_in_step_then_sign_in_error_is_cleared() + { + var auth = Substitute.For(); + auth.SignInInteractiveAsync(Arg.Any()) + .Returns(Task.FromResult>(new Fail(AuthErrorFactory.Failed("error")))); + + var sut = CreateSut(auth: auth); + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + await sut.SignIn.Execute(); + + await sut.Back.Execute(); + + sut.SignInHasError.ShouldBeFalse(); + } + + [Fact] + public async Task when_back_on_select_folders_step_then_step_is_sign_in() + { + var auth = Substitute.For(); + var profile = new AccountProfile("Test", "test@example.com"); + var authResult = AuthResultFactory.Create("token", "id", profile, DateTimeOffset.UtcNow.AddHours(1)); + auth.SignInInteractiveAsync(Arg.Any()) + .Returns(Task.FromResult>(new Ok(authResult))); + + var graph = Substitute.For(); + graph.GetRootFoldersAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult, GraphError>>(new Ok, GraphError>([]))); + + var sut = CreateSut(auth: auth, graph: graph); + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + await sut.SignIn.Execute(); + + await sut.Back.Execute(); + + sut.CurrentStep.ShouldBe(WizardStep.SignIn); + } + + [Fact] + public async Task when_cancel_is_executed_then_cancelled_event_is_raised() + { + var sut = CreateSut(); + var eventRaised = false; + sut.Cancelled += (_, _) => eventRaised = true; + + await sut.Cancel.Execute(); + + eventRaised.ShouldBeTrue(); + } + + [Fact] + public async Task when_add_account_succeeds_then_completed_event_is_raised() + { + var auth = Substitute.For(); + var profile = new AccountProfile("Test User", "test@example.com"); + var authResult = AuthResultFactory.Create("token", "account-id", profile, DateTimeOffset.UtcNow.AddHours(1)); + auth.SignInInteractiveAsync(Arg.Any()) + .Returns(Task.FromResult>(new Ok(authResult))); + + var graph = Substitute.For(); + graph.GetRootFoldersAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult, GraphError>>(new Ok, GraphError>([]))); + + var onboarding = Substitute.For(); + onboarding.CompleteOnboardingAsync(Arg.Any(), Arg.Any()) + .Returns(call => Task.FromResult>( + new Ok(call.Arg()))); + + var sut = CreateSut(auth: auth, graph: graph, onboarding: onboarding); + OneDriveAccount? completedAccount = null; + sut.Completed += (_, account) => completedAccount = account; + + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + await sut.SignIn.Execute(); + await sut.AddAccount.Execute(); + + completedAccount.ShouldNotBeNull(); + completedAccount.Profile.Email.ShouldBe("test@example.com"); + } + + [Fact] + public async Task when_add_account_fails_then_error_is_surfaced() + { + var auth = Substitute.For(); + var profile = new AccountProfile("Test User", "test@example.com"); + var authResult = AuthResultFactory.Create("token", "account-id", profile, DateTimeOffset.UtcNow.AddHours(1)); + auth.SignInInteractiveAsync(Arg.Any()) + .Returns(Task.FromResult>(new Ok(authResult))); + + var graph = Substitute.For(); + graph.GetRootFoldersAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult, GraphError>>(new Ok, GraphError>([]))); + + var onboarding = Substitute.For(); + onboarding.CompleteOnboardingAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>( + new Fail(new PersistenceUnexpectedError("DB failure")))); + + var sut = CreateSut(auth: auth, graph: graph, onboarding: onboarding); + + await sut.SelectProvider.Execute(ProviderKind.OneDrive); + await sut.SignIn.Execute(); + await sut.AddAccount.Execute(); + + sut.HasError.ShouldBeTrue(); + sut.ErrorMessage.ShouldContain("DB failure"); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Workspace/GivenAWorkspaceViewModel.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Workspace/GivenAWorkspaceViewModel.cs index aca4198..a168f12 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Workspace/GivenAWorkspaceViewModel.cs +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Workspace/GivenAWorkspaceViewModel.cs @@ -1,10 +1,18 @@ -using System.Collections.Generic; using AStar.Dev.CloudSyncFunctional.Accounts; +using AStar.Dev.CloudSyncFunctional.Auth; +using AStar.Dev.CloudSyncFunctional.Domain; +using AStar.Dev.CloudSyncFunctional.Graph; +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Tests.Unit.Infrastructure; +using AStar.Dev.CloudSyncFunctional.Wizard; using AStar.Dev.CloudSyncFunctional.Workspace; +using AStar.Dev.FunctionalParadigm; +using Microsoft.Extensions.DependencyInjection; +using ReactiveUI; -namespace AStar.Dev.CloudSyncFunctional.Tests.Unit; +namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.Workspace; -public class GivenAWorkspaceViewModel +public class GivenAWorkspaceViewModel : IClassFixture { [Fact] public void when_constructed_then_accounts_contains_four_entries() @@ -138,4 +146,105 @@ public void when_constructed_then_workspace_subtitle_contains_total_storage() sut.WorkspaceSubtitle.ShouldContain("TB"); } + + [Fact] + public void when_constructed_then_current_overlay_is_null() + { + var sut = new WorkspaceViewModel(); + + sut.CurrentOverlay.ShouldBeNull(); + } + + [Fact] + public void when_current_overlay_is_set_then_property_changed_fires() + { + var sut = new WorkspaceViewModel(); + var raisedProperties = new List(); + sut.PropertyChanged += (_, e) => raisedProperties.Add(e.PropertyName); + var vm = Substitute.For(); + + sut.CurrentOverlay = vm; + + raisedProperties.ShouldContain(nameof(WorkspaceViewModel.CurrentOverlay)); + } + + [Fact] + public void when_open_add_account_wizard_is_executed_then_current_overlay_is_set() + { + var auth = Substitute.For(); + var graph = Substitute.For(); + var onboarding = Substitute.For(); + var services = new ServiceCollection(); + services.AddTransient(_ => new AddAccountWizardViewModel(auth, graph, onboarding)); + var provider = services.BuildServiceProvider(); + + var sut = new WorkspaceViewModel(provider); + + sut.OpenAddAccountWizard.Execute().Subscribe(); + + sut.CurrentOverlay.ShouldNotBeNull(); + sut.CurrentOverlay.ShouldBeOfType(); + } + + [Fact] + public void when_wizard_cancelled_event_fires_then_current_overlay_is_null() + { + var auth = Substitute.For(); + var graph = Substitute.For(); + var onboarding = Substitute.For(); + var services = new ServiceCollection(); + services.AddTransient(_ => new AddAccountWizardViewModel(auth, graph, onboarding)); + var provider = services.BuildServiceProvider(); + var sut = new WorkspaceViewModel(provider); + sut.OpenAddAccountWizard.Execute().Subscribe(); + + var wizard = (AddAccountWizardViewModel)sut.CurrentOverlay!; + wizard.Cancel.Execute().Subscribe(); + + sut.CurrentOverlay.ShouldBeNull(); + } + + [Fact] + public void when_wizard_completed_then_current_overlay_is_null() + { + var auth = Substitute.For(); + var graph = Substitute.For(); + var onboarding = Substitute.For(); + var account = new OneDriveAccount { AccountId = "id", Profile = new AccountProfile("Name", "email@x.com"), SelectedFolderIds = [] }; + onboarding.CompleteOnboardingAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new Ok(account))); + + var services = new ServiceCollection(); + services.AddTransient(_ => new AddAccountWizardViewModel(auth, graph, onboarding)); + var provider = services.BuildServiceProvider(); + var sut = new WorkspaceViewModel(provider); + sut.OpenAddAccountWizard.Execute().Subscribe(); + + var wizard = (AddAccountWizardViewModel)sut.CurrentOverlay!; + wizard.SimulateCompleted(account); + + sut.CurrentOverlay.ShouldBeNull(); + } + + [Fact] + public void when_wizard_completed_then_account_is_added_to_accounts() + { + var auth = Substitute.For(); + var graph = Substitute.For(); + var onboarding = Substitute.For(); + var account = new OneDriveAccount { AccountId = "id", Profile = new AccountProfile("New User", "new@x.com"), SelectedFolderIds = ["f1"] }; + onboarding.CompleteOnboardingAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new Ok(account))); + + var services = new ServiceCollection(); + services.AddTransient(_ => new AddAccountWizardViewModel(auth, graph, onboarding)); + var provider = services.BuildServiceProvider(); + var sut = new WorkspaceViewModel(provider); + sut.OpenAddAccountWizard.Execute().Subscribe(); + + var wizard = (AddAccountWizardViewModel)sut.CurrentOverlay!; + wizard.SimulateCompleted(account); + + sut.Accounts.Count.ShouldBe(5); + } } From cccab2650fd2fa1afba6ea6326663a89bb8ddc07 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Wed, 27 May 2026 01:21:27 +0100 Subject: [PATCH 2/7] feat(wizard): implement add-account wizard with MSAL OneDrive auth (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MatchAsync overloads on Task> (Action and Func variants) - Implement AuthService: interactive MSAL sign-in, silent token refresh, sign-out, and token cache registration via MsalCacheHelper - Implement GraphService: GetRootFoldersAsync with drive context caching and paginated Graph API folder enumeration - Implement AccountOnboardingService (in-memory): marks account active, returns Ok result - Implement AddAccountWizardViewModel: all three wizard steps (ProviderSelection → SignIn → SelectFolders), SelectProvider command shows not-implemented banner for Google Drive and Dropbox, SignIn triggers MSAL browser flow, AddAccount fires Completed event - Wire WorkspaceViewModel: CurrentOverlay prop, OpenAddAccountWizard command resolves wizard from DI, Completed/Cancelled event handlers Closes #23 Co-Authored-By: Claude Sonnet 4.6 --- .../Auth/AuthService.cs | 96 +++++++++++- .../Graph/GraphService.cs | 47 +++++- .../Onboarding/AccountOnboardingService.cs | 10 +- .../Wizard/AddAccountWizardViewModel.cs | 143 +++++++++++++++++- .../Workspace/WorkspaceViewModel.cs | 38 ++++- .../ResultExtensions.cs | 15 +- 6 files changed, 328 insertions(+), 21 deletions(-) diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs index 6f105e8..07c2e20 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs @@ -1,25 +1,105 @@ using AStar.Dev.FunctionalParadigm; using Microsoft.Extensions.Logging; using Microsoft.Identity.Client; +using MELogLevel = Microsoft.Extensions.Logging.LogLevel; namespace AStar.Dev.CloudSyncFunctional.Auth; /// public sealed partial class AuthService(IPublicClientApplication app, ILogger logger) : IAuthService { + private static readonly string[] Scopes = ["Files.ReadWrite", "offline_access", "User.Read"]; + /// - public Task> SignInInteractiveAsync(CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task> SignInInteractiveAsync(CancellationToken ct = default) + { + try + { + var msalResult = await app + .AcquireTokenInteractive(Scopes) + .WithPrompt(Prompt.SelectAccount) + .WithUseEmbeddedWebView(false) + .ExecuteAsync(ct) + .ConfigureAwait(false); + + return new Ok(BuildAuthResult(msalResult)); + } + catch (MsalClientException ex) when (ex.ErrorCode is "authentication_canceled" or "user_canceled") + { + return new Fail(AuthErrorFactory.Cancelled()); + } + catch (OperationCanceledException) + { + return new Fail(AuthErrorFactory.Cancelled()); + } + catch (MsalException ex) + { + LogAuthFailed(logger, ex.Message); + return new Fail(AuthErrorFactory.Failed(ex.Message)); + } + catch (Exception ex) + { + LogAuthFailed(logger, ex.Message); + return new Fail(AuthErrorFactory.Failed(ex.Message)); + } + } /// - public Task> AcquireTokenSilentAsync(string accountId, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task> AcquireTokenSilentAsync(string accountId, CancellationToken ct = default) + { + try + { + var accounts = await app.GetAccountsAsync().ConfigureAwait(false); + var account = accounts.FirstOrDefault(a => a.HomeAccountId?.Identifier == accountId); + if (account is null) + return new Fail(AuthErrorFactory.Failed("Account not found in token cache.")); + + var msalResult = await app.AcquireTokenSilent(Scopes, account).ExecuteAsync(ct).ConfigureAwait(false); + + return new Ok(BuildAuthResult(msalResult)); + } + catch (MsalUiRequiredException) + { + return new Fail(AuthErrorFactory.Failed("Re-authentication required.")); + } + catch (Exception ex) + { + LogAuthFailed(logger, ex.Message); + return new Fail(AuthErrorFactory.Failed(ex.Message)); + } + } /// - public Task SignOutAsync(string accountId, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task SignOutAsync(string accountId, CancellationToken ct = default) + { + var accounts = await app.GetAccountsAsync().ConfigureAwait(false); + var account = accounts.FirstOrDefault(a => a.HomeAccountId?.Identifier == accountId); + if (account is not null) + await app.RemoveAsync(account).ConfigureAwait(false); + } /// - public Task> GetCachedAccountIdsAsync() - => throw new NotImplementedException(); + public async Task> GetCachedAccountIdsAsync() + { + var accounts = await app.GetAccountsAsync().ConfigureAwait(false); + + return accounts.Select(a => a.HomeAccountId.Identifier).ToList(); + } + + private static AuthResult BuildAuthResult(AuthenticationResult result) + { + var displayName = result.ClaimsPrincipal?.FindFirst("name")?.Value ?? result.Account.Username; + var email = result.ClaimsPrincipal?.FindFirst("preferred_username")?.Value + ?? result.ClaimsPrincipal?.FindFirst("email")?.Value + ?? result.Account.Username; + + return AuthResultFactory.Create( + result.AccessToken, + result.Account.HomeAccountId.Identifier, + new AccountProfile(displayName, email), + result.ExpiresOn); + } + + [LoggerMessage(Level = MELogLevel.Error, Message = "Authentication failed: {ErrorMessage}")] + private static partial void LogAuthFailed(ILogger logger, string errorMessage); } diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs index 1f41c2d..4d4f892 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs @@ -6,7 +6,50 @@ namespace AStar.Dev.CloudSyncFunctional.Graph; /// public sealed partial class GraphService(IGraphClientFactory clientFactory, ILogger logger) : IGraphService { + private static readonly string[] ChildrenSelect = ["id", "name", "folder", "parentReference"]; + /// - public Task, GraphError>> GetRootFoldersAsync(string accountId, string accessToken, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task, GraphError>> GetRootFoldersAsync(string accountId, string accessToken, CancellationToken ct = default) + { + try + { + var client = clientFactory.CreateClient(accessToken); + var drive = await client.Me.Drive.GetAsync(cancellationToken: ct).ConfigureAwait(false); + if (drive?.Id is null) + return new Fail, GraphError>(GraphErrorFactory.Unexpected("Drive ID was null.")); + + var root = await client.Drives[drive.Id].Root.GetAsync(cancellationToken: ct).ConfigureAwait(false); + if (root?.Id is null) + return new Fail, GraphError>(GraphErrorFactory.Unexpected("Root item ID was null.")); + + var page = await client.Drives[drive.Id].Items[root.Id].Children + .GetAsync(req => req.QueryParameters.Select = ChildrenSelect, ct) + .ConfigureAwait(false); + + var folders = new List(); + while (page?.Value is not null) + { + foreach (var item in page.Value.Where(i => i.Folder is not null)) + folders.Add(new DriveFolder(item.Id!, item.Name!, item.ParentReference?.Id)); + + if (page.OdataNextLink is null) + break; + + page = await client.Drives[drive.Id].Items[root.Id].Children + .WithUrl(page.OdataNextLink).GetAsync(cancellationToken: ct) + .ConfigureAwait(false); + } + + return new Ok, GraphError>(folders); + } + catch (Exception ex) + { + LogGraphFailed(logger, accountId, ex.Message); + + return new Fail, GraphError>(GraphErrorFactory.Unexpected(ex.Message)); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Graph API call failed for account {AccountId}: {ErrorMessage}")] + private static partial void LogGraphFailed(ILogger logger, string accountId, string errorMessage); } diff --git a/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs b/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs index d7463b1..06a543d 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs @@ -9,5 +9,13 @@ public sealed partial class AccountOnboardingService(ILogger public Task> CompleteOnboardingAsync(OneDriveAccount account, CancellationToken ct = default) - => throw new NotImplementedException(); + { + account.IsActive = true; + LogOnboardingComplete(logger, account.AccountId); + + return Task.FromResult>(new Ok(account)); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Account onboarding completed for {AccountId}")] + private static partial void LogOnboardingComplete(ILogger logger, string accountId); } diff --git a/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardViewModel.cs b/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardViewModel.cs index 4afff18..619c7d3 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardViewModel.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardViewModel.cs @@ -5,6 +5,7 @@ using AStar.Dev.CloudSyncFunctional.Domain; using AStar.Dev.CloudSyncFunctional.Graph; using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.FunctionalParadigm; using ReactiveUI; using RxUnit = System.Reactive.Unit; @@ -14,6 +15,11 @@ namespace AStar.Dev.CloudSyncFunctional.Wizard; public sealed class AddAccountWizardViewModel : ReactiveObject, IDisposable { private readonly CompositeDisposable _disposables = new(); + private readonly IAuthService _authService; + private readonly IGraphService _graphService; + private readonly IAccountOnboardingService _onboardingService; + private CancellationTokenSource? _authCts; + private AuthResult? _authResult; /// Gets or sets the current wizard step. public WizardStep CurrentStep @@ -134,19 +140,144 @@ public string ErrorMessage /// The service that persists the completed account. public AddAccountWizardViewModel(IAuthService authService, IGraphService graphService, IAccountOnboardingService onboardingService) { + _authService = authService; + _graphService = graphService; + _onboardingService = onboardingService; + var canSignIn = this.WhenAnyValue(x => x.IsWaitingForAuth, waiting => !waiting); - SelectProvider = ReactiveCommand.CreateFromTask((kind, ct) => Task.FromResult(RxUnit.Default)); - SignIn = ReactiveCommand.CreateFromTask(ct => Task.CompletedTask, canSignIn); - Back = ReactiveCommand.Create(() => { }); - AddAccount = ReactiveCommand.CreateFromTask(ct => Task.CompletedTask); - Cancel = ReactiveCommand.CreateFromTask(ct => Task.CompletedTask); + SelectProvider = ReactiveCommand.CreateFromTask((kind, ct) => ExecuteSelectProviderAsync(kind, ct)); + SignIn = ReactiveCommand.CreateFromTask(ct => ExecuteSignInAsync(ct), canSignIn); + Back = ReactiveCommand.Create(ExecuteBack); + AddAccount = ReactiveCommand.CreateFromTask(ct => ExecuteAddAccountAsync(ct)); + Cancel = ReactiveCommand.CreateFromTask(ExecuteCancelAsync); } /// - public void Dispose() => _disposables.Dispose(); + public void Dispose() + { + _disposables.Dispose(); + _authCts?.Dispose(); + } /// Test helper that raises the event directly. /// The account to pass with the event. internal void SimulateCompleted(OneDriveAccount account) => Completed?.Invoke(this, account); + + private async Task ExecuteSelectProviderAsync(ProviderKind kind, CancellationToken ct = default) + { + ShowNotImplemented = false; + NotImplementedMessage = string.Empty; + + if (kind == ProviderKind.OneDrive) + { + CurrentStep = WizardStep.SignIn; + } + else + { + ShowNotImplemented = true; + NotImplementedMessage = "Coming soon — not implemented yet"; + } + + return RxUnit.Default; + } + + private async Task ExecuteSignInAsync(CancellationToken ct) + { + _authCts?.Dispose(); + _authCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + + IsWaitingForAuth = true; + SignInHasError = false; + SignInStatusText = "Opening browser…"; + + await _authService.SignInInteractiveAsync(_authCts.Token) + .MatchAsync( + ok => + { + _authResult = ok; + IsSignedIn = true; + IsWaitingForAuth = false; + SignInStatusText = $"Signed in as {ok.Profile.Email}"; + CurrentStep = WizardStep.SelectFolders; + _ = LoadFoldersAsync(ok, CancellationToken.None); + }, + error => + { + IsWaitingForAuth = false; + if (error is AuthCancelledError) + { + CurrentStep = WizardStep.ProviderSelection; + SignInStatusText = string.Empty; + } + else + { + SignInHasError = true; + SignInStatusText = error.Message; + } + }); + } + + private async Task LoadFoldersAsync(AuthResult authResult, CancellationToken ct) + { + IsLoadingFolders = true; + await _graphService.GetRootFoldersAsync(authResult.AccountId, authResult.AccessToken, ct) + .MatchAsync( + folders => + { + Folders.Clear(); + foreach (var folder in folders) + Folders.Add(new WizardFolderItem { FolderId = folder.Id, Name = folder.Name }); + IsLoadingFolders = false; + }, + error => + { + HasError = true; + ErrorMessage = error.Message; + IsLoadingFolders = false; + }); + } + + private void ExecuteBack() + { + SignInHasError = false; + SignInStatusText = string.Empty; + ShowNotImplemented = false; + CurrentStep = CurrentStep switch + { + WizardStep.SignIn => WizardStep.ProviderSelection, + WizardStep.SelectFolders => WizardStep.SignIn, + _ => WizardStep.ProviderSelection + }; + } + + private async Task ExecuteAddAccountAsync(CancellationToken ct) + { + if (_authResult is null) + return; + + var account = new OneDriveAccount + { + AccountId = _authResult.AccountId, + Profile = _authResult.Profile, + SelectedFolderIds = Folders.Where(f => f.IsSelected).Select(f => f.FolderId).ToList() + }; + + await _onboardingService.CompleteOnboardingAsync(account, ct) + .MatchAsync( + finalAccount => Completed?.Invoke(this, finalAccount), + error => + { + HasError = true; + ErrorMessage = error.Message; + }); + } + + private Task ExecuteCancelAsync(CancellationToken ct = default) + { + _authCts?.Cancel(); + Cancelled?.Invoke(this, EventArgs.Empty); + + return Task.CompletedTask; + } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs b/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs index 43e6989..00510cb 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs @@ -77,7 +77,7 @@ public WorkspaceViewModel(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; SelectedAccount = Accounts[0]; - OpenAddAccountWizard = ReactiveCommand.Create(() => { }); // stub: does not set CurrentOverlay + OpenAddAccountWizard = ReactiveCommand.Create(ExecuteOpenAddAccountWizard); } /// Initialises a new with no DI services (design-time and test use). @@ -85,6 +85,42 @@ public WorkspaceViewModel() : this(EmptyServiceProvider.Instance) { } + private void ExecuteOpenAddAccountWizard() + { + var wizard = _serviceProvider.GetRequiredService(); + wizard.Completed += OnWizardCompleted; + wizard.Cancelled += OnWizardCancelled; + CurrentOverlay = wizard; + } + + private void OnWizardCompleted(object? sender, OneDriveAccount account) + { + DetachAndDisposeWizard(sender); + CurrentOverlay = null; + Accounts.Add(new AccountViewModel + { + Kind = ProviderKind.OneDrive, + Name = account.Profile.DisplayName, + Email = account.Profile.Email, + Status = SyncStatus.Ok, + FolderCount = account.SelectedFolderIds.Count + }); + SelectedAccount = Accounts[^1]; + this.RaisePropertyChanged(nameof(WorkspaceSubtitle)); + } + + private void OnWizardCancelled(object? sender, EventArgs e) + { + DetachAndDisposeWizard(sender); + CurrentOverlay = null; + } + + private static void DetachAndDisposeWizard(object? sender) + { + if (sender is AddAccountWizardViewModel wizard) + wizard.Dispose(); + } + private static ObservableCollection BuildAccounts() => [ BuildPersonalOneDrive(), diff --git a/src/AStar.Dev.FunctionalParadigm/ResultExtensions.cs b/src/AStar.Dev.FunctionalParadigm/ResultExtensions.cs index 8a2c233..4681b8e 100644 --- a/src/AStar.Dev.FunctionalParadigm/ResultExtensions.cs +++ b/src/AStar.Dev.FunctionalParadigm/ResultExtensions.cs @@ -88,7 +88,16 @@ public static TOut Match(this Result res /// A task that completes after the appropriate action runs. public static async Task MatchAsync(this Task> taskResult, Action onSuccess, Action onFailure) { - _ = await taskResult.ConfigureAwait(false); // stub: awaits but never calls callbacks + var result = await taskResult.ConfigureAwait(false); + switch (result) + { + case Ok ok: + onSuccess(ok.Value); + break; + case Fail fail: + onFailure(fail.Error); + break; + } } /// Asynchronously collapses the result to a single output value by handling both cases. @@ -101,8 +110,8 @@ public static async Task MatchAsync(this TaskA task that produces the value from whichever branch was taken. public static async Task MatchAsync(this Task> taskResult, Func onSuccess, Func onFailure) { - _ = await taskResult.ConfigureAwait(false); // stub: never calls functions + var result = await taskResult.ConfigureAwait(false); - return default!; + return result.Match(onSuccess, onFailure); } } From 0c7f8b9992120fe24c33624e447b47d9d35fad31 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Wed, 27 May 2026 01:36:23 +0100 Subject: [PATCH 3/7] test(auth,graph): add missing unit tests for AuthService, GraphService, GraphClientFactory (#23) - GivenAnAuthService: 10 tests covering all exception paths in SignInInteractiveAsync (authentication_canceled, user_canceled, OperationCancelled, MsalException, Exception), AcquireTokenSilentAsync (no cached account, MsalUiRequiredException), SignOutAsync (no match / match), and GetCachedAccountIdsAsync - GivenAGraphService: 2 tests exercising the catch branch in GetRootFoldersAsync via a throwing IGraphClientFactory - GivenAGraphClientFactory: 2 tests verifying CreateClient returns non-null and distinct instances per token 171 tests total, 0 failures, 0 warnings. Co-Authored-By: Claude Sonnet 4.6 --- .../Auth/GivenAnAuthService.cs | 161 ++++++++++++++++++ .../Graph/GivenAGraphClientFactory.cs | 21 +++ .../Graph/GivenAGraphService.cs | 39 +++++ 3 files changed, 221 insertions(+) create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Graph/GivenAGraphClientFactory.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Graph/GivenAGraphService.cs diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs new file mode 100644 index 0000000..d2d61bc --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs @@ -0,0 +1,161 @@ +using AStar.Dev.CloudSyncFunctional.Auth; +using AStar.Dev.FunctionalParadigm; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Client; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.Auth; + +public sealed class GivenAnAuthService +{ + private static AuthService CreateSut(IPublicClientApplication? app = null) => + new(app ?? Substitute.For(), Substitute.For>()); + + private static IAccount CreateAccount(string identifier) + { + var account = Substitute.For(); + var homeAccountId = new Microsoft.Identity.Client.AccountId(identifier, identifier, "tenant"); + account.HomeAccountId.Returns(homeAccountId); + return account; + } + + [Fact] + public async Task when_sign_in_throws_authentication_canceled_error_code_then_returns_cancelled_error() + { + var app = Substitute.For(); + app.When(a => a.AcquireTokenInteractive(Arg.Any>())) + .Do(_ => throw new MsalClientException("authentication_canceled", "Cancelled")); + var sut = CreateSut(app); + + var result = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken); + + var fail = result.ShouldBeOfType>(); + fail.Error.ShouldBeOfType(); + } + + [Fact] + public async Task when_sign_in_throws_user_canceled_error_code_then_returns_cancelled_error() + { + var app = Substitute.For(); + app.When(a => a.AcquireTokenInteractive(Arg.Any>())) + .Do(_ => throw new MsalClientException("user_canceled", "Cancelled")); + var sut = CreateSut(app); + + var result = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken); + + var fail = result.ShouldBeOfType>(); + fail.Error.ShouldBeOfType(); + } + + [Fact] + public async Task when_sign_in_throws_operation_cancelled_then_returns_cancelled_error() + { + var app = Substitute.For(); + app.When(a => a.AcquireTokenInteractive(Arg.Any>())) + .Do(_ => throw new OperationCanceledException()); + var sut = CreateSut(app); + + var result = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken); + + var fail = result.ShouldBeOfType>(); + fail.Error.ShouldBeOfType(); + } + + [Fact] + public async Task when_sign_in_throws_msal_exception_then_returns_failed_error() + { + var app = Substitute.For(); + app.When(a => a.AcquireTokenInteractive(Arg.Any>())) + .Do(_ => throw new MsalServiceException("some_code", "MSAL went wrong")); + var sut = CreateSut(app); + + var result = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken); + + var fail = result.ShouldBeOfType>(); + fail.Error.ShouldBeOfType(); + fail.Error.Message.ShouldContain("MSAL went wrong"); + } + + [Fact] + public async Task when_sign_in_throws_exception_then_returns_failed_error() + { + var app = Substitute.For(); + app.When(a => a.AcquireTokenInteractive(Arg.Any>())) + .Do(_ => throw new Exception("unexpected")); + var sut = CreateSut(app); + + var result = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken); + + var fail = result.ShouldBeOfType>(); + fail.Error.ShouldBeOfType(); + fail.Error.Message.ShouldContain("unexpected"); + } + + [Fact] + public async Task when_no_cached_accounts_then_returns_account_not_found_error() + { + var app = Substitute.For(); + app.GetAccountsAsync().Returns(Task.FromResult>([])); + var sut = CreateSut(app); + + var result = await sut.AcquireTokenSilentAsync("any-id", TestContext.Current.CancellationToken); + + var fail = result.ShouldBeOfType>(); + fail.Error.Message.ShouldContain("Account not found"); + } + + [Fact] + public async Task when_matching_account_found_but_ui_required_then_returns_failed_error() + { + var app = Substitute.For(); + var account = CreateAccount("target-id"); + app.GetAccountsAsync().Returns(Task.FromResult>([account])); + app.When(a => a.AcquireTokenSilent(Arg.Any>(), Arg.Any())) + .Do(_ => throw new MsalUiRequiredException("code", "message")); + var sut = CreateSut(app); + + var result = await sut.AcquireTokenSilentAsync("target-id", TestContext.Current.CancellationToken); + + var fail = result.ShouldBeOfType>(); + fail.Error.Message.ShouldContain("Re-authentication required"); + } + + [Fact] + public async Task when_sign_out_with_no_cached_accounts_then_remove_is_not_called() + { + var app = Substitute.For(); + app.GetAccountsAsync().Returns(Task.FromResult>([])); + var sut = CreateSut(app); + + await sut.SignOutAsync("any-id", TestContext.Current.CancellationToken); + + await app.DidNotReceive().RemoveAsync(Arg.Any()); + } + + [Fact] + public async Task when_sign_out_with_matching_account_then_remove_is_called() + { + var app = Substitute.For(); + var account = CreateAccount("target-id"); + app.GetAccountsAsync().Returns(Task.FromResult>([account])); + var sut = CreateSut(app); + + await sut.SignOutAsync("target-id", TestContext.Current.CancellationToken); + + await app.Received(1).RemoveAsync(Arg.Any()); + } + + [Fact] + public async Task when_two_accounts_cached_then_returns_both_identifiers() + { + var app = Substitute.For(); + var account1 = CreateAccount("id-1"); + var account2 = CreateAccount("id-2"); + app.GetAccountsAsync().Returns(Task.FromResult>([account1, account2])); + var sut = CreateSut(app); + + var result = await sut.GetCachedAccountIdsAsync(); + + result.ShouldContain("id-1"); + result.ShouldContain("id-2"); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Graph/GivenAGraphClientFactory.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Graph/GivenAGraphClientFactory.cs new file mode 100644 index 0000000..3959a99 --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Graph/GivenAGraphClientFactory.cs @@ -0,0 +1,21 @@ +using AStar.Dev.CloudSyncFunctional.Graph; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.Graph; + +public sealed class GivenAGraphClientFactory +{ + [Fact] + public void when_create_client_with_valid_token_then_returns_non_null_client() => + new GraphClientFactory().CreateClient("test-token").ShouldNotBeNull(); + + [Fact] + public void when_create_client_with_different_tokens_then_returns_distinct_instances() + { + var factory = new GraphClientFactory(); + + var client1 = factory.CreateClient("token-a"); + var client2 = factory.CreateClient("token-b"); + + client1.ShouldNotBeSameAs(client2); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Graph/GivenAGraphService.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Graph/GivenAGraphService.cs new file mode 100644 index 0000000..2bd4f2c --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Graph/GivenAGraphService.cs @@ -0,0 +1,39 @@ +using AStar.Dev.CloudSyncFunctional.Graph; +using AStar.Dev.FunctionalParadigm; +using Microsoft.Extensions.Logging; +using Microsoft.Graph; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.Graph; + +public sealed class GivenAGraphService +{ + private static GraphService CreateSut(IGraphClientFactory? factory = null) => + new(factory ?? Substitute.For(), Substitute.For>()); + + [Fact] + public async Task when_client_factory_throws_then_get_root_folders_returns_unexpected_graph_error() + { + var factory = Substitute.For(); + factory.CreateClient(Arg.Any()).Returns(_ => throw new Exception("network failure")); + var sut = CreateSut(factory); + + var result = await sut.GetRootFoldersAsync("account-id", "access-token", TestContext.Current.CancellationToken); + + var fail = result.ShouldBeOfType, GraphError>>(); + fail.Error.ShouldBeOfType(); + fail.Error.Message.ShouldContain("network failure"); + } + + [Fact] + public async Task when_client_factory_throws_then_error_message_is_not_null_or_empty() + { + var factory = Substitute.For(); + factory.CreateClient(Arg.Any()).Returns(_ => throw new Exception("network failure")); + var sut = CreateSut(factory); + + var result = await sut.GetRootFoldersAsync("account-id", "access-token", TestContext.Current.CancellationToken); + + var fail = result.ShouldBeOfType, GraphError>>(); + fail.Error.Message.ShouldNotBeNullOrEmpty(); + } +} From 444adc73cfe5f6cbbea6cfe6307f65d535479334 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Wed, 27 May 2026 01:48:59 +0100 Subject: [PATCH 4/7] fix(wizard): equalise footer button widths to 96px (#23) Cancel, Back, and Add account were sized to their label text, making them visually uneven. Fixed width of 96px accommodates the widest label with comfortable padding. Co-Authored-By: Claude Sonnet 4.6 --- .../Wizard/AddAccountWizardView.axaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml b/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml index e545f60..c9ce43e 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml +++ b/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml @@ -188,13 +188,13 @@ - + + IsVisible="{Binding CanGoBack}" Kind="Ghost" ButtonSize="Sm" Width="96" Margin="0,0,8,0"/> + Label="Add account" Kind="Primary" ButtonSize="Sm" Width="96"/> From 80e0f3f2f253dd3097c2be58112b7e7aca12b246 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Wed, 27 May 2026 01:53:06 +0100 Subject: [PATCH 5/7] fix(wizard): stretch provider tile buttons to equal width (#23) HorizontalContentAlignment="Stretch" only stretches content inside the button, not the button itself. Adding HorizontalAlignment="Stretch" on each tile button ensures all three fill the same available width. Co-Authored-By: Claude Sonnet 4.6 --- .../Wizard/AddAccountWizardView.axaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml b/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml index c9ce43e..dd3b5ad 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml +++ b/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardView.axaml @@ -42,6 +42,7 @@