diff --git a/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs index ac57dd2..0a8e64b 100644 --- a/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs @@ -3,6 +3,7 @@ using AStar.Dev.CloudSyncFunctional.Onboarding; using AStar.Dev.CloudSyncFunctional.Persistence; using AStar.Dev.CloudSyncFunctional.Persistence.Repositories; +using AStar.Dev.CloudSyncFunctional.Recovery; using AStar.Dev.CloudSyncFunctional.Wizard; using AStar.Dev.CloudSyncFunctional.Workspace; using Avalonia; @@ -83,6 +84,7 @@ private static void ConfigureServices(IServiceCollection services, IConfiguratio services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); } diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs index c2e4c97..fa177f3 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs @@ -22,25 +22,25 @@ public async Task> SignInInteractiveAsync(Cancella .ExecuteAsync(cancellationToken) .ConfigureAwait(false); - return new Ok(BuildAuthResult(msalResult)); + return BuildAuthResult(msalResult); } catch (MsalClientException ex) when (ex.ErrorCode is "authentication_canceled" or "user_canceled") { - return new Fail(AuthErrorFactory.Cancelled()); + return AuthErrorFactory.Cancelled(); } catch (OperationCanceledException) { - return new Fail(AuthErrorFactory.Cancelled()); + return AuthErrorFactory.Cancelled(); } catch (MsalException ex) { LogAuthFailed(logger, ex.Message); - return new Fail(AuthErrorFactory.Failed(ex.Message)); + return AuthErrorFactory.Failed(ex.Message); } catch (Exception ex) { LogAuthFailed(logger, ex.Message); - return new Fail(AuthErrorFactory.Failed(ex.Message)); + return AuthErrorFactory.Failed(ex.Message); } } @@ -52,20 +52,20 @@ public async Task> AcquireTokenSilentAsync(string 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.")); + return AuthErrorFactory.Failed("Account not found in token cache."); var msalResult = await app.AcquireTokenSilent(Scopes, account).ExecuteAsync(cancellationToken).ConfigureAwait(false); - return new Ok(BuildAuthResult(msalResult)); + return BuildAuthResult(msalResult); } catch (MsalUiRequiredException) { - return new Fail(AuthErrorFactory.Failed("Re-authentication required.")); + return AuthErrorFactory.Failed("Re-authentication required."); } catch (Exception ex) { LogAuthFailed(logger, ex.Message); - return new Fail(AuthErrorFactory.Failed(ex.Message)); + return AuthErrorFactory.Failed(ex.Message); } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/GraphClientFactory.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphClientFactory.cs index 3c4a809..368a61e 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Graph/GraphClientFactory.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphClientFactory.cs @@ -11,10 +11,9 @@ public sealed class GraphClientFactory : IGraphClientFactory public Result CreateClient(string accessToken) { if (string.IsNullOrWhiteSpace(accessToken)) - return new Fail(GraphErrorFactory.Unexpected("Access token must not be null or whitespace.")); + return GraphErrorFactory.Unexpected("Access token must not be null or whitespace."); - return new Ok( - new GraphServiceClient(new BaseBearerTokenAuthenticationProvider(new StaticAccessTokenProvider(accessToken)))); + return new GraphServiceClient(new BaseBearerTokenAuthenticationProvider(new StaticAccessTokenProvider(accessToken))); } private sealed class StaticAccessTokenProvider(string token) : IAccessTokenProvider diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs index b3ee247..7aab820 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs @@ -28,7 +28,7 @@ private async Task> ExecuteGraphOperationAsync(string a { LogGraphFailed(logger, accountId, ex.Message); - return new Fail(GraphErrorFactory.Unexpected(ex.Message)); + return GraphErrorFactory.Unexpected(ex.Message); } } @@ -59,7 +59,7 @@ private static Task, GraphError>> GetFoldersFromPagesAs var folders = (foldersSoFar ?? []).Concat(GetFoldersFromPage(page)).ToList(); return page.OdataNextLink is null - ? Task.FromResult, GraphError>>(new Ok, GraphError>(folders)) + ? Task.FromResult, GraphError>>(folders) : GetFolderPageAsync(client, driveFound, rootFound, page.OdataNextLink, cancellationToken) .BindAsync(nextPage => GetFoldersFromPagesAsync(client, driveFound, rootFound, nextPage, folders, cancellationToken)); } diff --git a/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs b/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs index 7b20683..5646149 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs @@ -26,18 +26,18 @@ public async Task> CompleteOnboardingA _ => { LogOnboardingComplete(logger, account.AccountId.Value); - return new Ok(account); + return account; }, error => { LogOnboardingFailed(logger, account.AccountId.Value, error.Message); - return new Fail(error); + return error; }); } private Task> UpsertSyncRulesAsync(OneDriveAccount account, CancellationToken cancellationToken) => account.SelectedFolders.Aggregate( - Task.FromResult>(new Ok(Unit.Default)), + Task.FromResult>(Unit.Default), (current, folder) => current.BindAsync(_ => syncRuleRepository.UpsertAsync(CreateSyncRule(account, folder), cancellationToken))); private static SyncRuleEntity CreateSyncRule(OneDriveAccount account, SelectedFolder folder) => diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs index e6753ed..9db8b44 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs @@ -41,15 +41,15 @@ public async Task> UpsertAsync(AccountEntity enti context.Entry(existing).CurrentValues.SetValues(entity); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return new Ok(Unit.Default); + return Unit.Default; } catch (DbUpdateConcurrencyException) { - return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + return PersistenceErrorFactory.ConcurrencyConflict(); } catch (DbUpdateException ex) { - return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + return PersistenceErrorFactory.Unexpected(ex.Message); } } @@ -66,11 +66,11 @@ public async Task> DeleteAsync(AccountId id, Canc await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } - return new Ok(Unit.Default); + return Unit.Default; } catch (DbUpdateException ex) { - return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + return PersistenceErrorFactory.Unexpected(ex.Message); } } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/DriveStateRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/DriveStateRepository.cs index fe6db6a..0ac26af 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/DriveStateRepository.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/DriveStateRepository.cs @@ -33,15 +33,15 @@ public async Task> UpsertAsync(DriveStateEntity e context.Entry(existing).CurrentValues.SetValues(entity); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return new Ok(Unit.Default); + return Unit.Default; } catch (DbUpdateConcurrencyException) { - return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + return PersistenceErrorFactory.ConcurrencyConflict(); } catch (DbUpdateException ex) { - return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + return PersistenceErrorFactory.Unexpected(ex.Message); } } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/FileClassificationRuleRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/FileClassificationRuleRepository.cs index 3353884..b498d6e 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/FileClassificationRuleRepository.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/FileClassificationRuleRepository.cs @@ -29,15 +29,15 @@ public async Task> UpsertAsync(FileClassification context.Entry(existing).CurrentValues.SetValues(entity); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return new Ok(Unit.Default); + return Unit.Default; } catch (DbUpdateConcurrencyException) { - return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + return PersistenceErrorFactory.ConcurrencyConflict(); } catch (DbUpdateException ex) { - return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + return PersistenceErrorFactory.Unexpected(ex.Message); } } @@ -54,11 +54,11 @@ public async Task> DeleteAsync(string id, Cancell await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } - return new Ok(Unit.Default); + return Unit.Default; } catch (DbUpdateException ex) { - return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + return PersistenceErrorFactory.Unexpected(ex.Message); } } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncRepository.cs index 88fb3c6..30062c4 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncRepository.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncRepository.cs @@ -10,31 +10,43 @@ public interface ISyncRepository { /// Retrieves all pending conflicts for a given account. /// The account identifier. - /// Cancellation token. + /// Cancellation token. /// All pending conflicts for the account. Task> GetPendingConflictsAsync(AccountId accountId, CancellationToken cancellationToken = default); /// Upserts a sync conflict. /// The conflict to upsert. - /// Cancellation token. + /// Cancellation token. /// Ok on success, Fail on error. Task> UpsertConflictAsync(SyncConflictEntity entity, CancellationToken cancellationToken = default); /// Marks a conflict as resolved. /// The conflict identifier. - /// Cancellation token. + /// Cancellation token. /// Ok on success, Fail on error. Task> ResolveConflictAsync(SyncConflictId id, CancellationToken cancellationToken = default); /// Upserts a sync job. /// The job to upsert. - /// Cancellation token. + /// Cancellation token. /// Ok on success, Fail on error. Task> UpsertJobAsync(SyncJobEntity entity, CancellationToken cancellationToken = default); /// Removes all completed jobs for a given account. /// The account identifier. - /// Cancellation token. + /// Cancellation token. /// Ok on success, Fail on error. Task> ClearCompletedJobsAsync(AccountId accountId, CancellationToken cancellationToken = default); + + /// Retrieves all jobs in "Running" state for a given account (crash survivors). + /// The account identifier. + /// Cancellation token. + /// Jobs with Status == "Running". + Task> GetInterruptedJobsAsync(AccountId accountId, CancellationToken cancellationToken = default); + + /// Resets all "Running" jobs for an account to "Interrupted" status. + /// The account identifier. + /// Cancellation token. + /// Ok on success, Fail on error. + Task> ResetInterruptedJobsAsync(AccountId accountId, CancellationToken cancellationToken = default); } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRepository.cs index 818c550..d46b656 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRepository.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRepository.cs @@ -12,6 +12,8 @@ public sealed class SyncRepository(IDbContextFactory dbFactory) : private const string PendingState = "Pending"; private const string ResolvedState = "Resolved"; private const string CompletedStatus = "Completed"; + private const string RunningStatus = "Running"; + private const string InterruptedStatus = "Interrupted"; /// public async Task> GetPendingConflictsAsync(AccountId accountId, CancellationToken cancellationToken = default) @@ -38,15 +40,15 @@ public async Task> UpsertConflictAsync(SyncConfli context.Entry(existing).CurrentValues.SetValues(entity); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return new Ok(Unit.Default); + return Unit.Default; } catch (DbUpdateConcurrencyException) { - return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + return PersistenceErrorFactory.ConcurrencyConflict(); } catch (DbUpdateException ex) { - return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + return PersistenceErrorFactory.Unexpected(ex.Message); } } @@ -57,21 +59,20 @@ public async Task> ResolveConflictAsync(SyncConfl { await using var context = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); var existing = await context.SyncConflicts.FindAsync([id], cancellationToken).ConfigureAwait(false); - if (existing is not null) - { - existing.State = ResolvedState; - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } + if (existing is null) return Unit.Default; + + existing.State = ResolvedState; + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return new Ok(Unit.Default); + return Unit.Default; } catch (DbUpdateConcurrencyException) { - return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + return PersistenceErrorFactory.ConcurrencyConflict(); } catch (DbUpdateException ex) { - return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + return PersistenceErrorFactory.Unexpected(ex.Message); } } @@ -88,15 +89,15 @@ public async Task> UpsertJobAsync(SyncJobEntity e context.Entry(existing).CurrentValues.SetValues(entity); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return new Ok(Unit.Default); + return Unit.Default; } catch (DbUpdateConcurrencyException) { - return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + return PersistenceErrorFactory.ConcurrencyConflict(); } catch (DbUpdateException ex) { - return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + return PersistenceErrorFactory.Unexpected(ex.Message); } } @@ -113,11 +114,48 @@ public async Task> ClearCompletedJobsAsync(Accoun context.SyncJobs.RemoveRange(completed); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return new Ok(Unit.Default); + return Unit.Default; + } + catch (DbUpdateException ex) + { + return PersistenceErrorFactory.Unexpected(ex.Message); + } + } + + /// + public async Task> GetInterruptedJobsAsync(AccountId accountId, CancellationToken cancellationToken = default) + { + await using var context = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + + return await context.SyncJobs + .AsNoTracking() + .Where(j => j.AccountId == accountId && j.Status == RunningStatus) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task> ResetInterruptedJobsAsync(AccountId accountId, CancellationToken cancellationToken = default) + { + try + { + await using var context = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var running = await context.SyncJobs + .Where(j => j.AccountId == accountId && j.Status == RunningStatus) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + running.ForEach(job => job.Status = InterruptedStatus); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return Unit.Default; + } + catch (DbUpdateConcurrencyException) + { + return PersistenceErrorFactory.ConcurrencyConflict(); } catch (DbUpdateException ex) { - return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + return PersistenceErrorFactory.Unexpected(ex.Message); } } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRuleRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRuleRepository.cs index 497343b..b455ad4 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRuleRepository.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRuleRepository.cs @@ -34,15 +34,15 @@ public async Task> UpsertAsync(SyncRuleEntity ent context.Entry(existing).CurrentValues.SetValues(entity); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return new Ok(Unit.Default); + return Unit.Default; } catch (DbUpdateConcurrencyException) { - return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + return PersistenceErrorFactory.ConcurrencyConflict(); } catch (DbUpdateException ex) { - return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + return PersistenceErrorFactory.Unexpected(ex.Message); } } @@ -59,11 +59,11 @@ public async Task> DeleteAsync(SyncRuleId id, Can await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } - return new Ok(Unit.Default); + return Unit.Default; } catch (DbUpdateException ex) { - return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + return PersistenceErrorFactory.Unexpected(ex.Message); } } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncedItemRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncedItemRepository.cs index b44744a..00def13 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncedItemRepository.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncedItemRepository.cs @@ -45,15 +45,15 @@ public async Task> UpsertAsync(SyncedItemEntity e context.Entry(existing).CurrentValues.SetValues(entity); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return new Ok(Unit.Default); + return Unit.Default; } catch (DbUpdateConcurrencyException) { - return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + return PersistenceErrorFactory.ConcurrencyConflict(); } catch (DbUpdateException ex) { - return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + return PersistenceErrorFactory.Unexpected(ex.Message); } } @@ -70,11 +70,11 @@ public async Task> DeleteAsync(SyncedItemId id, C await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } - return new Ok(Unit.Default); + return Unit.Default; } catch (DbUpdateException ex) { - return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + return PersistenceErrorFactory.Unexpected(ex.Message); } } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Recovery/ISyncRecoveryService.cs b/src/AStar.Dev.CloudSyncFunctional/Recovery/ISyncRecoveryService.cs new file mode 100644 index 0000000..8e929e3 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Recovery/ISyncRecoveryService.cs @@ -0,0 +1,20 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Recovery; + +/// Detects and resets interrupted syncs on application startup. +public interface ISyncRecoveryService +{ + /// Scans all active accounts for jobs that were in-flight when the app crashed. + /// Cancellation token. + /// Recovery info per interrupted account. + Task> DetectAsync(CancellationToken cancellationToken = default); + + /// Resets interrupted jobs for a specific account. + /// The account identifier. + /// Cancellation token. + /// Ok on success, Fail on persistence error. + Task> ResetAsync(AccountId accountId, CancellationToken cancellationToken = default); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Recovery/InterruptedSyncInfo.cs b/src/AStar.Dev.CloudSyncFunctional/Recovery/InterruptedSyncInfo.cs new file mode 100644 index 0000000..e4f1ed9 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Recovery/InterruptedSyncInfo.cs @@ -0,0 +1,10 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +namespace AStar.Dev.CloudSyncFunctional.Recovery; + +/// Describes an interrupted sync detected on startup for a specific account. +/// The account identifier. +/// The human-readable account display name. +/// True when a delta token exists and sync can resume from the interruption point. +/// A user-facing explanation of the recovery status. +public sealed record InterruptedSyncInfo(AccountId AccountId, string AccountName, bool CanResume, string Message); diff --git a/src/AStar.Dev.CloudSyncFunctional/Recovery/SyncRecoveryService.cs b/src/AStar.Dev.CloudSyncFunctional/Recovery/SyncRecoveryService.cs new file mode 100644 index 0000000..f5d262b --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Recovery/SyncRecoveryService.cs @@ -0,0 +1,40 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Repositories; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Recovery; + +/// +public sealed class SyncRecoveryService(IAccountRepository accountRepository, ISyncRepository syncRepository, IDriveStateRepository driveStateRepository) : ISyncRecoveryService +{ + private const string ResumeMessage = "Sync resumed from last checkpoint."; + private const string NoCheckpointMessage = "Sync interrupted. No checkpoint found — a full sync will run on next attempt."; + + /// + public async Task> DetectAsync(CancellationToken cancellationToken = default) + { + var accounts = await accountRepository.GetAllAsync(cancellationToken).ConfigureAwait(false); + var results = new List(); + + foreach (var account in accounts) + { + var interrupted = await syncRepository.GetInterruptedJobsAsync(account.Id, cancellationToken).ConfigureAwait(false); + if (interrupted.Count == 0) + continue; + + var driveState = await driveStateRepository.GetByAccountAsync(account.Id, cancellationToken).ConfigureAwait(false); + var canResume = driveState.Match( + state => !string.IsNullOrEmpty(state.DeltaLink), + _ => false); + + results.Add(new InterruptedSyncInfo(account.Id, account.Profile.DisplayName.Value, canResume, canResume ? ResumeMessage : NoCheckpointMessage)); + } + + return results; + } + + /// + public Task> ResetAsync(AccountId accountId, CancellationToken cancellationToken = default) + => syncRepository.ResetInterruptedJobsAsync(accountId, cancellationToken); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs b/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs index 61f9f37..2c5bb96 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Workspace/WorkspaceViewModel.cs @@ -4,6 +4,7 @@ using AStar.Dev.CloudSyncFunctional.FolderTree; using AStar.Dev.CloudSyncFunctional.Persistence.Entities; using AStar.Dev.CloudSyncFunctional.Persistence.Repositories; +using AStar.Dev.CloudSyncFunctional.Recovery; using AStar.Dev.CloudSyncFunctional.Wizard; using Microsoft.Extensions.DependencyInjection; using ReactiveUI; @@ -16,6 +17,7 @@ public class WorkspaceViewModel : ReactiveObject { private readonly IServiceProvider _serviceProvider; private readonly IAccountRepository? _accountRepository; + private readonly ISyncRecoveryService? _recoveryService; /// Gets all cloud storage accounts registered in the workspace. public ObservableCollection Accounts { get; } @@ -39,6 +41,13 @@ public ReactiveObject? CurrentOverlay set => this.RaiseAndSetIfChanged(ref field, value); } + /// Gets or sets a value indicating whether any syncs were interrupted (e.g. due to a crash) and need recovery. + public bool HasInterruptedSyncs + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } + /// Gets the command that opens the add-account wizard overlay. public ReactiveCommand OpenAddAccountWizard { get; } @@ -77,16 +86,18 @@ public ReactiveObject? CurrentOverlay /// 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) + /// Optional recovery service used to detect interrupted syncs on startup. + public WorkspaceViewModel(IServiceProvider serviceProvider, IAccountRepository accountRepository, ISyncRecoveryService? recoveryService = null) { _serviceProvider = serviceProvider; _accountRepository = accountRepository; + _recoveryService = recoveryService; Accounts = []; OpenAddAccountWizard = ReactiveCommand.Create(ExecuteOpenAddAccountWizard); } /// Loads persisted accounts from the database and populates . - /// Cancellation token. + /// Cancellation token. /// A task that completes when accounts are loaded and added to the collection. public async Task LoadPersistedAccountsAsync(CancellationToken cancellationToken = default) { @@ -98,6 +109,12 @@ public async Task LoadPersistedAccountsAsync(CancellationToken cancellationToken Accounts.Add(vm); if (Accounts.Count > 0 && SelectedAccount is null) SelectedAccount = Accounts[0]; + + if (_recoveryService is not null) + { + var interrupted = await _recoveryService.DetectAsync(cancellationToken); + HasInterruptedSyncs = interrupted.Count > 0; + } } /// Initialises a new using the provided service provider (design-time and test use). diff --git a/src/AStar.Dev.FunctionalParadigm/Result.cs b/src/AStar.Dev.FunctionalParadigm/Result.cs index bf30a80..137186f 100644 --- a/src/AStar.Dev.FunctionalParadigm/Result.cs +++ b/src/AStar.Dev.FunctionalParadigm/Result.cs @@ -2,19 +2,9 @@ public abstract record Result { - public static implicit operator TResult(Result result) => - result switch - { - Ok ok => ok.Value, - _ => default! - }; - public static implicit operator TError(Result result) => - result switch - { - Fail fail => fail.Error, - _ => default! - }; + public static implicit operator Result(TResult value) => new Ok(value); + public static implicit operator Result(TError error) => new Fail(error); } public record Ok(TResult Value) : Result; diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenASyncRepositoryInterruptedJobs.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenASyncRepositoryInterruptedJobs.cs new file mode 100644 index 0000000..be6de53 --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenASyncRepositoryInterruptedJobs.cs @@ -0,0 +1,176 @@ +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.Integration.TestData; +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Integration.Repositories; + +public class GivenASyncRepositoryInterruptedJobs(DatabaseFixture db) : IClassFixture +{ + private SyncRepository CreateSut() => new(new TestDbContextFactory(db.Connection)); + private AccountRepository CreateAccountSut() => new(new TestDbContextFactory(db.Connection)); + + private static AccountEntity CreateAccountEntity(AccountId accountId) => + new() + { + Id = accountId, + Profile = new AccountProfileEntity + { + DisplayName = new DisplayName("Test User"), + Email = new EmailAddress("test@example.com") + }, + IsActive = true, + DriveId = new DriveId("drive-1"), + SyncConfig = new AccountSyncConfig { LocalSyncPath = new LocalSyncPath("/home/test/OneDrive"), WorkerCount = 4 } + }; + + private static SyncJobEntity CreateRunningJob(AccountId accountId) => + new() + { + Id = new SyncJobId(Guid.NewGuid().ToString()), + AccountId = accountId, + RemotePath = "/Documents/report.docx", + LocalPath = "/home/test/OneDrive/Documents/report.docx", + JobType = "Download", + Status = "Running", + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-10) + }; + + private static SyncJobEntity CreateCompletedJob(AccountId accountId) => + new() + { + Id = new SyncJobId(Guid.NewGuid().ToString()), + AccountId = accountId, + RemotePath = "/Documents/done.docx", + LocalPath = "/home/test/OneDrive/Documents/done.docx", + JobType = "Download", + Status = "Completed", + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-20) + }; + + private static SyncJobEntity CreatePendingJob(AccountId accountId) => + new() + { + Id = new SyncJobId(Guid.NewGuid().ToString()), + AccountId = accountId, + RemotePath = "/Documents/pending.docx", + LocalPath = "/home/test/OneDrive/Documents/pending.docx", + JobType = "Download", + Status = "Pending", + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-2) + }; + + [Fact] + public async Task when_running_jobs_exist_then_get_interrupted_jobs_returns_them() + { + var accountId = new AccountId(Guid.NewGuid().ToString()); + await CreateAccountSut().UpsertAsync(CreateAccountEntity(accountId), CancellationToken.None); + var job = CreateRunningJob(accountId); + var sut = CreateSut(); + await sut.UpsertJobAsync(job, CancellationToken.None); + + var result = await sut.GetInterruptedJobsAsync(accountId, CancellationToken.None); + + result.Count.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task when_no_running_jobs_exist_then_get_interrupted_jobs_returns_empty() + { + var accountId = new AccountId(Guid.NewGuid().ToString()); + await CreateAccountSut().UpsertAsync(CreateAccountEntity(accountId), CancellationToken.None); + var sut = CreateSut(); + + var result = await sut.GetInterruptedJobsAsync(accountId, CancellationToken.None); + + result.ShouldBeEmpty(); + } + + [Fact] + public async Task when_running_jobs_are_reset_then_status_becomes_interrupted() + { + var accountId = new AccountId(Guid.NewGuid().ToString()); + await CreateAccountSut().UpsertAsync(CreateAccountEntity(accountId), CancellationToken.None); + var job = CreateRunningJob(accountId); + var sut = CreateSut(); + await sut.UpsertJobAsync(job, CancellationToken.None); + + var resetResult = await sut.ResetInterruptedJobsAsync(accountId, CancellationToken.None); + var remainingRunning = await sut.GetInterruptedJobsAsync(accountId, CancellationToken.None); + + resetResult.ShouldBeOfType>(); + remainingRunning.ShouldBeEmpty(); + } + + [Fact] + public async Task when_only_completed_jobs_exist_then_get_interrupted_jobs_returns_empty() + { + var accountId = new AccountId(Guid.NewGuid().ToString()); + await CreateAccountSut().UpsertAsync(CreateAccountEntity(accountId), CancellationToken.None); + var sut = CreateSut(); + await sut.UpsertJobAsync(CreateCompletedJob(accountId), CancellationToken.None); + + var result = await sut.GetInterruptedJobsAsync(accountId, CancellationToken.None); + + result.ShouldBeEmpty(); + } + + [Fact] + public async Task when_only_pending_jobs_exist_then_get_interrupted_jobs_returns_empty() + { + var accountId = new AccountId(Guid.NewGuid().ToString()); + await CreateAccountSut().UpsertAsync(CreateAccountEntity(accountId), CancellationToken.None); + var sut = CreateSut(); + await sut.UpsertJobAsync(CreatePendingJob(accountId), CancellationToken.None); + + var result = await sut.GetInterruptedJobsAsync(accountId, CancellationToken.None); + + result.ShouldBeEmpty(); + } + + [Fact] + public async Task when_no_running_jobs_exist_then_reset_interrupted_jobs_returns_ok() + { + var accountId = new AccountId(Guid.NewGuid().ToString()); + await CreateAccountSut().UpsertAsync(CreateAccountEntity(accountId), CancellationToken.None); + var sut = CreateSut(); + + var result = await sut.ResetInterruptedJobsAsync(accountId, CancellationToken.None); + + result.ShouldBeOfType>(); + } + + [Fact] + public async Task when_multiple_running_jobs_exist_then_all_are_reset_by_reset_interrupted_jobs() + { + var accountId = new AccountId(Guid.NewGuid().ToString()); + await CreateAccountSut().UpsertAsync(CreateAccountEntity(accountId), CancellationToken.None); + var sut = CreateSut(); + await sut.UpsertJobAsync(CreateRunningJob(accountId), CancellationToken.None); + await sut.UpsertJobAsync(CreateRunningJob(accountId), CancellationToken.None); + await sut.UpsertJobAsync(CreateRunningJob(accountId), CancellationToken.None); + + await sut.ResetInterruptedJobsAsync(accountId, CancellationToken.None); + var remaining = await sut.GetInterruptedJobsAsync(accountId, CancellationToken.None); + + remaining.ShouldBeEmpty(); + } + + [Fact] + public async Task when_running_jobs_belong_to_different_account_then_get_interrupted_jobs_returns_empty_for_queried_account() + { + var ownerAccountId = new AccountId(Guid.NewGuid().ToString()); + var otherAccountId = new AccountId(Guid.NewGuid().ToString()); + var accountSut = CreateAccountSut(); + await accountSut.UpsertAsync(CreateAccountEntity(ownerAccountId), CancellationToken.None); + await accountSut.UpsertAsync(CreateAccountEntity(otherAccountId), CancellationToken.None); + var sut = CreateSut(); + await sut.UpsertJobAsync(CreateRunningJob(otherAccountId), CancellationToken.None); + + var result = await sut.GetInterruptedJobsAsync(ownerAccountId, CancellationToken.None); + + result.ShouldBeEmpty(); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Recovery/GivenASyncRecoveryService.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Recovery/GivenASyncRecoveryService.cs new file mode 100644 index 0000000..46b8f8c --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Recovery/GivenASyncRecoveryService.cs @@ -0,0 +1,213 @@ +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.Recovery; +using AStar.Dev.FunctionalParadigm; +using FpUnit = AStar.Dev.FunctionalParadigm.Unit; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.Recovery; + +public class GivenASyncRecoveryService +{ + private static AccountEntity CreateAccountEntity(string id = "acc-1", string name = "Test User", string email = "test@example.com") => + new() + { + Id = new AccountId(id), + Profile = new AccountProfileEntity { DisplayName = new DisplayName(name), Email = new EmailAddress(email) }, + IsActive = true, + DriveId = new DriveId("drive-1"), + SyncConfig = new AccountSyncConfig { LocalSyncPath = new LocalSyncPath("/home/test/OneDrive"), WorkerCount = 4 } + }; + + private static SyncJobEntity CreateRunningJob(AccountId accountId) => + new() + { + Id = new SyncJobId(Guid.NewGuid().ToString()), + AccountId = accountId, + RemotePath = "/Documents/file.docx", + LocalPath = "/home/test/OneDrive/Documents/file.docx", + JobType = "Download", + Status = "Running", + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5) + }; + + [Fact] + public async Task when_no_accounts_have_running_jobs_then_detect_returns_empty() + { + var accountId = new AccountId("acc-1"); + var accountRepo = Substitute.For(); + accountRepo.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([CreateAccountEntity()])); + var syncRepo = Substitute.For(); + syncRepo.GetInterruptedJobsAsync(accountId, Arg.Any()) + .Returns(Task.FromResult>([])); + var driveStateRepo = Substitute.For(); + var sut = new SyncRecoveryService(accountRepo, syncRepo, driveStateRepo); + + var result = await sut.DetectAsync(CancellationToken.None); + + result.ShouldBeEmpty(); + } + + [Fact] + public async Task when_account_has_running_jobs_and_delta_link_exists_then_can_resume_is_true() + { + var accountId = new AccountId("acc-2"); + var accountEntity = CreateAccountEntity("acc-2", "Alice", "alice@example.com"); + var accountRepo = Substitute.For(); + accountRepo.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([accountEntity])); + var syncRepo = Substitute.For(); + syncRepo.GetInterruptedJobsAsync(accountId, Arg.Any()) + .Returns(Task.FromResult>([CreateRunningJob(accountId)])); + var driveStateRepo = Substitute.For(); + driveStateRepo.GetByAccountAsync(accountId, Arg.Any()) + .Returns(Task.FromResult>(new Some(new DriveStateEntity + { + AccountId = accountId, + DeltaLink = "https://graph.microsoft.com/v1.0/drives/xxx/root/delta?token=abc", + LastCheckedAt = DateTimeOffset.UtcNow.AddHours(-1) + }))); + var sut = new SyncRecoveryService(accountRepo, syncRepo, driveStateRepo); + + var result = await sut.DetectAsync(CancellationToken.None); + + result.Count.ShouldBe(1); + result[0].CanResume.ShouldBeTrue(); + result[0].AccountId.ShouldBe(accountId); + } + + [Fact] + public async Task when_account_has_running_jobs_and_no_delta_link_then_can_resume_is_false() + { + var accountId = new AccountId("acc-3"); + var accountEntity = CreateAccountEntity("acc-3", "Bob", "bob@example.com"); + var accountRepo = Substitute.For(); + accountRepo.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([accountEntity])); + var syncRepo = Substitute.For(); + syncRepo.GetInterruptedJobsAsync(accountId, Arg.Any()) + .Returns(Task.FromResult>([CreateRunningJob(accountId)])); + var driveStateRepo = Substitute.For(); + driveStateRepo.GetByAccountAsync(accountId, Arg.Any()) + .Returns(Task.FromResult>(new None())); + var sut = new SyncRecoveryService(accountRepo, syncRepo, driveStateRepo); + + var result = await sut.DetectAsync(CancellationToken.None); + + result.Count.ShouldBe(1); + result[0].CanResume.ShouldBeFalse(); + result[0].AccountId.ShouldBe(accountId); + } + + [Fact] + public async Task when_reset_is_called_then_sync_repository_reset_method_is_called() + { + var accountId = new AccountId("acc-4"); + var accountRepo = Substitute.For(); + var syncRepo = Substitute.For(); + syncRepo.ResetInterruptedJobsAsync(accountId, Arg.Any()) + .Returns(Task.FromResult>(new Ok(FpUnit.Default))); + var driveStateRepo = Substitute.For(); + var sut = new SyncRecoveryService(accountRepo, syncRepo, driveStateRepo); + + await sut.ResetAsync(accountId, CancellationToken.None); + + await syncRepo.Received(1).ResetInterruptedJobsAsync(accountId, Arg.Any()); + } + + [Fact] + public async Task when_account_has_running_jobs_then_detect_returns_account_display_name_in_account_name() + { + var accountId = new AccountId("acc-5"); + var accountEntity = CreateAccountEntity("acc-5", "Carol Smith", "carol@example.com"); + var accountRepo = Substitute.For(); + accountRepo.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([accountEntity])); + var syncRepo = Substitute.For(); + syncRepo.GetInterruptedJobsAsync(accountId, Arg.Any()) + .Returns(Task.FromResult>([CreateRunningJob(accountId)])); + var driveStateRepo = Substitute.For(); + driveStateRepo.GetByAccountAsync(accountId, Arg.Any()) + .Returns(Task.FromResult>(new None())); + var sut = new SyncRecoveryService(accountRepo, syncRepo, driveStateRepo); + + var result = await sut.DetectAsync(CancellationToken.None); + + result[0].AccountName.ShouldBe("Carol Smith"); + } + + [Fact] + public async Task when_drive_state_has_empty_delta_link_then_can_resume_is_false() + { + var accountId = new AccountId("acc-6"); + var accountEntity = CreateAccountEntity("acc-6", "Dave", "dave@example.com"); + var accountRepo = Substitute.For(); + accountRepo.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([accountEntity])); + var syncRepo = Substitute.For(); + syncRepo.GetInterruptedJobsAsync(accountId, Arg.Any()) + .Returns(Task.FromResult>([CreateRunningJob(accountId)])); + var driveStateRepo = Substitute.For(); + driveStateRepo.GetByAccountAsync(accountId, Arg.Any()) + .Returns(Task.FromResult>(new Some(new DriveStateEntity + { + AccountId = accountId, + DeltaLink = string.Empty, + LastCheckedAt = DateTimeOffset.UtcNow.AddHours(-1) + }))); + var sut = new SyncRecoveryService(accountRepo, syncRepo, driveStateRepo); + + var result = await sut.DetectAsync(CancellationToken.None); + + result[0].CanResume.ShouldBeFalse(); + } + + [Fact] + public async Task when_can_resume_is_true_then_message_is_resume_message() + { + var accountId = new AccountId("acc-7"); + var accountEntity = CreateAccountEntity("acc-7", "Eve", "eve@example.com"); + var accountRepo = Substitute.For(); + accountRepo.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([accountEntity])); + var syncRepo = Substitute.For(); + syncRepo.GetInterruptedJobsAsync(accountId, Arg.Any()) + .Returns(Task.FromResult>([CreateRunningJob(accountId)])); + var driveStateRepo = Substitute.For(); + driveStateRepo.GetByAccountAsync(accountId, Arg.Any()) + .Returns(Task.FromResult>(new Some(new DriveStateEntity + { + AccountId = accountId, + DeltaLink = "https://graph.microsoft.com/v1.0/drives/yyy/root/delta?token=xyz", + LastCheckedAt = DateTimeOffset.UtcNow.AddHours(-2) + }))); + var sut = new SyncRecoveryService(accountRepo, syncRepo, driveStateRepo); + + var result = await sut.DetectAsync(CancellationToken.None); + + result[0].Message.ShouldBe("Sync resumed from last checkpoint."); + } + + [Fact] + public async Task when_can_resume_is_false_then_message_is_no_checkpoint_message() + { + var accountId = new AccountId("acc-8"); + var accountEntity = CreateAccountEntity("acc-8", "Frank", "frank@example.com"); + var accountRepo = Substitute.For(); + accountRepo.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([accountEntity])); + var syncRepo = Substitute.For(); + syncRepo.GetInterruptedJobsAsync(accountId, Arg.Any()) + .Returns(Task.FromResult>([CreateRunningJob(accountId)])); + var driveStateRepo = Substitute.For(); + driveStateRepo.GetByAccountAsync(accountId, Arg.Any()) + .Returns(Task.FromResult>(new None())); + var sut = new SyncRecoveryService(accountRepo, syncRepo, driveStateRepo); + + var result = await sut.DetectAsync(CancellationToken.None); + + result[0].Message.ShouldBe("Sync interrupted. No checkpoint found — a full sync will run on next attempt."); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Workspace/GivenAWorkspaceViewModel.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Workspace/GivenAWorkspaceViewModel.cs index 4409d68..a8ae658 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Workspace/GivenAWorkspaceViewModel.cs +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Workspace/GivenAWorkspaceViewModel.cs @@ -6,6 +6,7 @@ using AStar.Dev.CloudSyncFunctional.Persistence.Entities; using AStar.Dev.CloudSyncFunctional.Persistence.Repositories; using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.CloudSyncFunctional.Recovery; using AStar.Dev.CloudSyncFunctional.Tests.Unit.Infrastructure; using AStar.Dev.CloudSyncFunctional.Wizard; using AStar.Dev.CloudSyncFunctional.Workspace; @@ -311,4 +312,43 @@ public async Task when_load_persisted_accounts_is_called_then_first_account_is_a sut.SelectedAccount.ShouldNotBeNull(); sut.SelectedAccount!.Email.ShouldBe("bob@x.com"); } + + [Fact] + public async Task when_interrupted_syncs_are_detected_then_has_interrupted_syncs_is_true() + { + var accountRepo = Substitute.For(); + accountRepo.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([])); + var recoveryService = Substitute.For(); + recoveryService.DetectAsync(Arg.Any()) + .Returns(Task.FromResult>( + [ + new InterruptedSyncInfo( + new Persistence.ValueObjects.AccountId("acc-1"), + "Test User", + CanResume: true, + "Sync resumed from last checkpoint.") + ])); + var sut = new WorkspaceViewModel(new ServiceCollection().BuildServiceProvider(), accountRepo, recoveryService); + + await sut.LoadPersistedAccountsAsync(CancellationToken.None); + + sut.HasInterruptedSyncs.ShouldBeTrue(); + } + + [Fact] + public async Task when_no_interrupted_syncs_then_has_interrupted_syncs_is_false() + { + var accountRepo = Substitute.For(); + accountRepo.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([])); + var recoveryService = Substitute.For(); + recoveryService.DetectAsync(Arg.Any()) + .Returns(Task.FromResult>([])); + var sut = new WorkspaceViewModel(new ServiceCollection().BuildServiceProvider(), accountRepo, recoveryService); + + await sut.LoadPersistedAccountsAsync(CancellationToken.None); + + sut.HasInterruptedSyncs.ShouldBeFalse(); + } }