diff --git a/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs index 1fd4893..8b80004 100644 --- a/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs @@ -40,7 +40,11 @@ public override void OnFrameworkInitializationCompleted() ApplyDatabaseMigrations(_serviceProvider); if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - desktop.MainWindow = new MainWindow(_serviceProvider.GetRequiredService()); + { + var viewModel = _serviceProvider.GetRequiredService(); + desktop.MainWindow = new MainWindow(viewModel); + _ = viewModel.LoadPersistedAccountsAsync(CancellationToken.None); + } base.OnFrameworkInitializationCompleted(); } diff --git a/src/AStar.Dev.CloudSyncFunctional/Domain/OneDriveAccount.cs b/src/AStar.Dev.CloudSyncFunctional/Domain/OneDriveAccount.cs index cf179c0..98718c0 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Domain/OneDriveAccount.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Domain/OneDriveAccount.cs @@ -17,6 +17,6 @@ public sealed class OneDriveAccount /// 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; } = []; + /// Gets the folders selected for sync, carrying both Graph item ID and display name. + public IReadOnlyList SelectedFolders { get; init; } = []; } diff --git a/src/AStar.Dev.CloudSyncFunctional/Domain/SelectedFolder.cs b/src/AStar.Dev.CloudSyncFunctional/Domain/SelectedFolder.cs new file mode 100644 index 0000000..28fd781 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Domain/SelectedFolder.cs @@ -0,0 +1,6 @@ +namespace AStar.Dev.CloudSyncFunctional.Domain; + +/// A OneDrive root folder selected for sync, carrying both the Graph item ID and the display name. +/// The Graph drive item identifier. +/// The folder display name (e.g. "Documents"). +public readonly record struct SelectedFolder(string Id, string Name); diff --git a/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs b/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs index bd9d684..9fa38a4 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs @@ -36,13 +36,13 @@ public async Task> CompleteOnboardingA private async Task> UpsertSyncRulesAsync(OneDriveAccount account, CancellationToken ct) { - foreach (var folderId in account.SelectedFolderIds) + foreach (var folder in account.SelectedFolders) { var rule = new SyncRuleEntity { Id = new SyncRuleId(Guid.NewGuid().ToString()), AccountId = new AccountId(account.AccountId), - RemotePath = folderId, + RemotePath = $"/{folder.Name}", RuleType = RuleType.Include }; diff --git a/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardViewModel.cs b/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardViewModel.cs index 6cb440a..3e67b91 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardViewModel.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Wizard/AddAccountWizardViewModel.cs @@ -275,7 +275,7 @@ private async Task ExecuteAddAccountAsync(CancellationToken ct) { AccountId = _authResult.AccountId, Profile = _authResult.Profile, - SelectedFolderIds = Folders.Where(f => f.IsSelected).Select(f => f.FolderId).ToList() + SelectedFolders = Folders.Where(f => f.IsSelected).Select(f => new SelectedFolder(f.FolderId, f.Name)).ToList() }; await _onboardingService.CompleteOnboardingAsync(account, ct) diff --git a/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs b/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs index 00510cb..d3f2f39 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs @@ -2,6 +2,8 @@ using AStar.Dev.CloudSyncFunctional.Accounts; using AStar.Dev.CloudSyncFunctional.Domain; using AStar.Dev.CloudSyncFunctional.FolderTree; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.Repositories; using AStar.Dev.CloudSyncFunctional.Wizard; using Microsoft.Extensions.DependencyInjection; using ReactiveUI; @@ -13,9 +15,10 @@ namespace AStar.Dev.CloudSyncFunctional.Workspace; public class WorkspaceViewModel : ReactiveObject { private readonly IServiceProvider _serviceProvider; + private readonly IAccountRepository? _accountRepository; /// Gets all cloud storage accounts registered in the workspace. - public ObservableCollection Accounts { get; } = BuildAccounts(); + public ObservableCollection Accounts { get; } /// Gets or sets the currently selected account. public AccountViewModel? SelectedAccount @@ -71,16 +74,43 @@ public ReactiveObject? CurrentOverlay /// 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 using the provided service provider. + /// Initialises a new using the provided service provider and account repository (runtime path). + /// The DI container used to resolve the wizard ViewModel on demand. + /// Repository used to load persisted accounts on startup. + public WorkspaceViewModel(IServiceProvider serviceProvider, IAccountRepository accountRepository) + { + _serviceProvider = serviceProvider; + _accountRepository = accountRepository; + Accounts = []; + OpenAddAccountWizard = ReactiveCommand.Create(ExecuteOpenAddAccountWizard); + } + + /// Loads persisted accounts from the database and populates . + /// Cancellation token. + /// A task that completes when accounts are loaded and added to the collection. + public async Task LoadPersistedAccountsAsync(CancellationToken ct = default) + { + if (_accountRepository is null) + return; + + var entities = await _accountRepository.GetAllAsync(ct); + foreach (var vm in entities.Select(MapToViewModel)) + Accounts.Add(vm); + if (Accounts.Count > 0 && SelectedAccount is null) + SelectedAccount = Accounts[0]; + } + + /// Initialises a new using the provided service provider (design-time and test use). /// The DI container used to resolve the wizard ViewModel on demand. public WorkspaceViewModel(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; + Accounts = BuildAccounts(); SelectedAccount = Accounts[0]; OpenAddAccountWizard = ReactiveCommand.Create(ExecuteOpenAddAccountWizard); } - /// Initialises a new with no DI services (design-time and test use). + /// Initialises a new with no DI services (design-time use). public WorkspaceViewModel() : this(EmptyServiceProvider.Instance) { } @@ -103,12 +133,21 @@ private void OnWizardCompleted(object? sender, OneDriveAccount account) Name = account.Profile.DisplayName, Email = account.Profile.Email, Status = SyncStatus.Ok, - FolderCount = account.SelectedFolderIds.Count + FolderCount = account.SelectedFolders.Count }); SelectedAccount = Accounts[^1]; this.RaisePropertyChanged(nameof(WorkspaceSubtitle)); } + private static AccountViewModel MapToViewModel(AccountEntity entity) => + new() + { + Kind = ProviderKind.OneDrive, + Name = entity.Profile.DisplayName.Value, + Email = entity.Profile.Email.Value, + Status = SyncStatus.Ok + }; + private void OnWizardCancelled(object? sender, EventArgs e) { DetachAndDisposeWizard(sender); diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Onboarding/GivenAnAccountOnboardingServiceIntegration.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Onboarding/GivenAnAccountOnboardingServiceIntegration.cs index 41f5c59..7c1969d 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Onboarding/GivenAnAccountOnboardingServiceIntegration.cs +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Onboarding/GivenAnAccountOnboardingServiceIntegration.cs @@ -22,12 +22,12 @@ private SyncRuleRepository CreateSyncRuleRepo() => private AccountRepository CreateAccountRepo() => new(new TestDbContextFactory(db.Connection)); - private static OneDriveAccount CreateAccount(params string[] folderIds) => + private static OneDriveAccount CreateAccount(params string[] folderNames) => new() { AccountId = Guid.NewGuid().ToString(), Profile = new AccountProfile("Test User", "test@example.com"), - SelectedFolderIds = folderIds + SelectedFolders = folderNames.Select((name, i) => new SelectedFolder($"graph-id-{i}", name)).ToList() }; [Fact] @@ -76,4 +76,38 @@ public async Task when_complete_onboarding_is_called_with_no_folders_then_no_syn rules.ShouldBeEmpty(); } + + [Fact] + public async Task when_complete_onboarding_is_called_then_sync_rule_remote_path_is_slash_prefixed_folder_name() + { + var account = new OneDriveAccount + { + AccountId = Guid.NewGuid().ToString(), + Profile = new AccountProfile("Test User", "test@example.com"), + SelectedFolders = [new SelectedFolder("graph-item-id-abc123", "Documents")] + }; + var sut = CreateSut(); + + await sut.CompleteOnboardingAsync(account, CancellationToken.None); + var rules = await CreateSyncRuleRepo().GetByAccountAsync(new AccountId(account.AccountId), CancellationToken.None); + + rules[0].RemotePath.ShouldBe("/Documents"); + } + + [Fact] + public async Task when_complete_onboarding_is_called_then_sync_rule_remote_path_does_not_contain_graph_item_id() + { + var account = new OneDriveAccount + { + AccountId = Guid.NewGuid().ToString(), + Profile = new AccountProfile("Test User", "test@example.com"), + SelectedFolders = [new SelectedFolder("graph-item-id-abc123", "Pictures")] + }; + var sut = CreateSut(); + + await sut.CompleteOnboardingAsync(account, CancellationToken.None); + var rules = await CreateSyncRuleRepo().GetByAccountAsync(new AccountId(account.AccountId), CancellationToken.None); + + rules[0].RemotePath.ShouldNotContain("graph-item-id-abc123"); + } } diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Onboarding/GivenAnAccountOnboardingService.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Onboarding/GivenAnAccountOnboardingService.cs index e453317..e1148b4 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Onboarding/GivenAnAccountOnboardingService.cs +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Onboarding/GivenAnAccountOnboardingService.cs @@ -29,7 +29,7 @@ private static OneDriveAccount CreateAccount() => { AccountId = "test-account-id", Profile = new AccountProfile("Test User", "test@example.com"), - SelectedFolderIds = ["folder-1", "folder-2"] + SelectedFolders = [new SelectedFolder("folder-1-id", "folder-1"), new SelectedFolder("folder-2-id", "folder-2")] }; [Fact] diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Workspace/GivenAWorkspaceViewModel.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Workspace/GivenAWorkspaceViewModel.cs index a168f12..c0cfe33 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Workspace/GivenAWorkspaceViewModel.cs +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Workspace/GivenAWorkspaceViewModel.cs @@ -3,6 +3,9 @@ using AStar.Dev.CloudSyncFunctional.Domain; using AStar.Dev.CloudSyncFunctional.Graph; using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.Repositories; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; using AStar.Dev.CloudSyncFunctional.Tests.Unit.Infrastructure; using AStar.Dev.CloudSyncFunctional.Wizard; using AStar.Dev.CloudSyncFunctional.Workspace; @@ -210,7 +213,7 @@ 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 = [] }; + var account = new OneDriveAccount { AccountId = "id", Profile = new AccountProfile("Name", "email@x.com"), SelectedFolders = [] }; onboarding.CompleteOnboardingAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new Ok(account))); @@ -232,7 +235,7 @@ 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"] }; + var account = new OneDriveAccount { AccountId = "id", Profile = new AccountProfile("New User", "new@x.com"), SelectedFolders = [new SelectedFolder("f1-id", "f1")] }; onboarding.CompleteOnboardingAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new Ok(account))); @@ -247,4 +250,64 @@ public void when_wizard_completed_then_account_is_added_to_accounts() sut.Accounts.Count.ShouldBe(5); } + + [Fact] + public async Task when_load_persisted_accounts_is_called_then_stored_account_appears_in_accounts() + { + var accountRepo = Substitute.For(); + accountRepo.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>( + [ + new AccountEntity + { + Id = new AccountId("acc-1"), + Profile = new AccountProfileEntity { DisplayName = new DisplayName("Alice"), Email = new EmailAddress("alice@x.com") }, + IsActive = true, + DriveId = new DriveId("drive-1"), + SyncConfig = new AccountSyncConfig { LocalSyncPath = new LocalSyncPath("/home/alice/OneDrive"), WorkerCount = 8 } + } + ])); + var sut = new WorkspaceViewModel(new ServiceCollection().BuildServiceProvider(), accountRepo); + + await sut.LoadPersistedAccountsAsync(CancellationToken.None); + + sut.Accounts.ShouldContain(a => a.Email == "alice@x.com"); + } + + [Fact] + public async Task when_load_persisted_accounts_is_called_with_no_accounts_then_accounts_collection_is_empty() + { + var accountRepo = Substitute.For(); + accountRepo.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([])); + var sut = new WorkspaceViewModel(new ServiceCollection().BuildServiceProvider(), accountRepo); + + await sut.LoadPersistedAccountsAsync(CancellationToken.None); + + sut.Accounts.ShouldBeEmpty(); + } + + [Fact] + public async Task when_load_persisted_accounts_is_called_then_first_account_is_auto_selected() + { + var accountRepo = Substitute.For(); + accountRepo.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>( + [ + new AccountEntity + { + Id = new AccountId("acc-1"), + Profile = new AccountProfileEntity { DisplayName = new DisplayName("Bob"), Email = new EmailAddress("bob@x.com") }, + IsActive = true, + DriveId = new DriveId("drive-1"), + SyncConfig = new AccountSyncConfig { LocalSyncPath = new LocalSyncPath("/home/bob/OneDrive"), WorkerCount = 8 } + } + ])); + var sut = new WorkspaceViewModel(new ServiceCollection().BuildServiceProvider(), accountRepo); + + await sut.LoadPersistedAccountsAsync(CancellationToken.None); + + sut.SelectedAccount.ShouldNotBeNull(); + sut.SelectedAccount!.Email.ShouldBe("bob@x.com"); + } }