Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/AStar.Dev.CloudSyncFunctional/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ public override void OnFrameworkInitializationCompleted()
ApplyDatabaseMigrations(_serviceProvider);

if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
desktop.MainWindow = new MainWindow(_serviceProvider.GetRequiredService<WorkspaceViewModel>());
{
var viewModel = _serviceProvider.GetRequiredService<WorkspaceViewModel>();
desktop.MainWindow = new MainWindow(viewModel);
_ = viewModel.LoadPersistedAccountsAsync(CancellationToken.None);
}

base.OnFrameworkInitializationCompleted();
}
Expand Down
4 changes: 2 additions & 2 deletions src/AStar.Dev.CloudSyncFunctional/Domain/OneDriveAccount.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ public sealed class OneDriveAccount
/// <summary>Gets the Graph drive ID for this account's OneDrive.</summary>
public string? DriveId { get; init; }

/// <summary>Gets the IDs of folders the user selected for sync.</summary>
public IReadOnlyList<string> SelectedFolderIds { get; init; } = [];
/// <summary>Gets the folders selected for sync, carrying both Graph item ID and display name.</summary>
public IReadOnlyList<SelectedFolder> SelectedFolders { get; init; } = [];
}
6 changes: 6 additions & 0 deletions src/AStar.Dev.CloudSyncFunctional/Domain/SelectedFolder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace AStar.Dev.CloudSyncFunctional.Domain;

/// <summary>A OneDrive root folder selected for sync, carrying both the Graph item ID and the display name.</summary>
/// <param name="Id">The Graph drive item identifier.</param>
/// <param name="Name">The folder display name (e.g. "Documents").</param>
public readonly record struct SelectedFolder(string Id, string Name);
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@ public async Task<Result<OneDriveAccount, PersistenceError>> CompleteOnboardingA

private async Task<Result<Unit, PersistenceError>> 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
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
47 changes: 43 additions & 4 deletions src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,9 +15,10 @@ namespace AStar.Dev.CloudSyncFunctional.Workspace;
public class WorkspaceViewModel : ReactiveObject
{
private readonly IServiceProvider _serviceProvider;
private readonly IAccountRepository? _accountRepository;

/// <summary>Gets all cloud storage accounts registered in the workspace.</summary>
public ObservableCollection<AccountViewModel> Accounts { get; } = BuildAccounts();
public ObservableCollection<AccountViewModel> Accounts { get; }

/// <summary>Gets or sets the currently selected account.</summary>
public AccountViewModel? SelectedAccount
Expand Down Expand Up @@ -71,16 +74,43 @@ public ReactiveObject? CurrentOverlay
/// <summary>Gets a formatted subtitle summarising account count and total storage capacity.</summary>
public string WorkspaceSubtitle => $"{Accounts.Count} accounts · {Accounts.Sum(a => a.TotalBytes) / 1_099_511_627_776.0:F1} TB total";

/// <summary>Initialises a new <see cref="WorkspaceViewModel"/> using the provided service provider.</summary>
/// <summary>Initialises a new <see cref="WorkspaceViewModel"/> using the provided service provider and account repository (runtime path).</summary>
/// <param name="serviceProvider">The DI container used to resolve the wizard ViewModel on demand.</param>
/// <param name="accountRepository">Repository used to load persisted accounts on startup.</param>
public WorkspaceViewModel(IServiceProvider serviceProvider, IAccountRepository accountRepository)
{
_serviceProvider = serviceProvider;
_accountRepository = accountRepository;
Accounts = [];
OpenAddAccountWizard = ReactiveCommand.Create(ExecuteOpenAddAccountWizard);
}

/// <summary>Loads persisted accounts from the database and populates <see cref="Accounts"/>.</summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that completes when accounts are loaded and added to the collection.</returns>
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];
}

/// <summary>Initialises a new <see cref="WorkspaceViewModel"/> using the provided service provider (design-time and test use).</summary>
/// <param name="serviceProvider">The DI container used to resolve the wizard ViewModel on demand.</param>
public WorkspaceViewModel(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
Accounts = BuildAccounts();
SelectedAccount = Accounts[0];
OpenAddAccountWizard = ReactiveCommand.Create(ExecuteOpenAddAccountWizard);
}

/// <summary>Initialises a new <see cref="WorkspaceViewModel"/> with no DI services (design-time and test use).</summary>
/// <summary>Initialises a new <see cref="WorkspaceViewModel"/> with no DI services (design-time use).</summary>
public WorkspaceViewModel() : this(EmptyServiceProvider.Instance)
{
}
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -210,7 +213,7 @@ public void when_wizard_completed_then_current_overlay_is_null()
var auth = Substitute.For<IAuthService>();
var graph = Substitute.For<IGraphService>();
var onboarding = Substitute.For<IAccountOnboardingService>();
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<OneDriveAccount>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Result<OneDriveAccount, PersistenceError>>(new Ok<OneDriveAccount, PersistenceError>(account)));

Expand All @@ -232,7 +235,7 @@ public void when_wizard_completed_then_account_is_added_to_accounts()
var auth = Substitute.For<IAuthService>();
var graph = Substitute.For<IGraphService>();
var onboarding = Substitute.For<IAccountOnboardingService>();
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<OneDriveAccount>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Result<OneDriveAccount, PersistenceError>>(new Ok<OneDriveAccount, PersistenceError>(account)));

Expand All @@ -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<IAccountRepository>();
accountRepo.GetAllAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AccountEntity>>(
[
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<IAccountRepository>();
accountRepo.GetAllAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AccountEntity>>([]));
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<IAccountRepository>();
accountRepo.GetAllAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AccountEntity>>(
[
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");
}
}
Loading