diff --git a/.gitignore b/.gitignore index 9b5f5588..06edc766 100644 --- a/.gitignore +++ b/.gitignore @@ -356,3 +356,6 @@ healthchecksdb # Jetbrains Rider run folder .run/ + +# Visual Studio Code +.vscode/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..26b900c5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +## Branch & PR Guidelines + +### Overview +This repository uses a branch-based development workflow. Please follow the conventions below when working in this project, especially when creating branches, writing PR titles, and generating commits or documentation. + +### Branch Naming +Use the following prefixes: +- `feature/` for new features +- `fix/` for bug fixes +- `refactor/` for refactorings +- `docs/` for refactorings +- `test/` for tests + +Avoid slashes other than the prefix. + +### PR Titles +Format: +`[] ` + +Allowed types: `feature`, `fix`, `refactor`, `docs`, `test` + +Examples: +- `[feature] Add user registration` +- `[bugfix] Fix memory leak in sync module` + +### Notes for Agents +- Never propose changes directly on `master`. +- Always create a branch using the proper prefix. +- PR titles must follow the documented format. \ No newline at end of file diff --git a/docs/client-deployment.md b/docs/client-deployment.md index 31f86656..486fddcb 100644 --- a/docs/client-deployment.md +++ b/docs/client-deployment.md @@ -25,11 +25,11 @@ These projects are written in **C#** and target a specific version of **.NET**. { "LocalDebugUrl": "", "DevelopmentUrl": "", - "StagingUrl": "", - "ProductionUrl": "", - "UpdatesDefinitionUrl": "" - } - ``` + "StagingUrl": "", + "ProductionUrl": "", + "UpdatesDefinitionUrl": "" + } + ``` Fill in any relevant URLs or other settings your environment requires. diff --git a/docs/server-deployment.md b/docs/server-deployment.md index 57453861..7b3029af 100644 --- a/docs/server-deployment.md +++ b/docs/server-deployment.md @@ -71,7 +71,8 @@ Below is the JSON structure indicating which properties must be set. In particul "DatabaseName": "" }, "AppSettings": { - "Secret": "YOUR_UNIQUE_RANDOM_SEED" + "Secret": "YOUR_UNIQUE_RANDOM_SEED", + "AnnouncementsUrl": "" } } ``` @@ -97,7 +98,8 @@ Below is the JSON structure indicating which properties must be set. In particul "SignalR:ConnectionString": "", "CosmosDb:ConnectionString": "", "CosmosDb:DatabaseName": "", - "AppSettings:Secret": "YOUR_UNIQUE_RANDOM_SEED" + "AppSettings:Secret": "YOUR_UNIQUE_RANDOM_SEED", + "AppSettings:AnnouncementsUrl": "" } } ``` @@ -136,6 +138,7 @@ cd ByteSync.Functions # App Settings dotnet user-secrets set "AppSettings:Secret" "YOUR_UNIQUE_RANDOM_SEED" + dotnet user-secrets set "AppSettings:AnnouncementsUrl" "" ``` Repeat these steps for **ByteSync.Functions.IntegrationTests** and **ByteSync.ServerCommon.Tests**, navigating to each project's directory and setting the same secrets. diff --git a/docs/synchronization-rules.md b/docs/synchronization-rules.md new file mode 100644 index 00000000..b4ff97ae --- /dev/null +++ b/docs/synchronization-rules.md @@ -0,0 +1,13 @@ +# Synchronization rules + +ByteSync supports creating synchronization rules to automatically trigger actions when comparison conditions are met. Rules can compare file or directory attributes using different elements and operators. + +## Comparison properties + +- **Content** +- **Date** +- **Size** +- **Presence** +- **Name** (supports `Equals` and `NotEquals` operators, wildcard `*` is allowed) + +Use `Name` to match items based on their file name. When a pattern contains `*`, the rule interprets it as a wildcard. diff --git a/src/ByteSync.Client/Assets/Resources/Resources.Designer.cs b/src/ByteSync.Client/Assets/Resources/Resources.Designer.cs index 1dca1a45..972d279f 100644 --- a/src/ByteSync.Client/Assets/Resources/Resources.Designer.cs +++ b/src/ByteSync.Client/Assets/Resources/Resources.Designer.cs @@ -173,7 +173,13 @@ internal static string AtomicConditionDescription_ComparisonElement_Size { return ResourceManager.GetString("AtomicConditionDescription_ComparisonElement_Size", resourceCulture); } } - + + internal static string AtomicConditionDescription_ComparisonElement_Name { + get { + return ResourceManager.GetString("AtomicConditionDescription_ComparisonElement_Name", resourceCulture); + } + } + internal static string AtomicConditionDescription_ComparisonElement_Presence { get { return ResourceManager.GetString("AtomicConditionDescription_ComparisonElement_Presence", resourceCulture); @@ -516,6 +522,12 @@ internal static string AtomicConditionEdit_Destination { } } + internal static string AtomicConditionEdit_NamePlaceholder { + get { + return ResourceManager.GetString("AtomicConditionEdit_NamePlaceholder", resourceCulture); + } + } + /// /// Recherche une chaîne localisée semblable à Equals. /// @@ -605,6 +617,15 @@ internal static string AtomicConditionEdit_Size { return ResourceManager.GetString("AtomicConditionEdit_Size", resourceCulture); } } + + /// + /// Looks up a localized string similar to Name. + /// + internal static string AtomicConditionEdit_Name { + get { + return ResourceManager.GetString("AtomicConditionEdit_Name", resourceCulture); + } + } internal static string AtomicConditionEdit_Presence { get { diff --git a/src/ByteSync.Client/Assets/Resources/Resources.fr.resx b/src/ByteSync.Client/Assets/Resources/Resources.fr.resx index 9d008d06..7e1c7f77 100644 --- a/src/ByteSync.Client/Assets/Resources/Resources.fr.resx +++ b/src/ByteSync.Client/Assets/Resources/Resources.fr.resx @@ -127,7 +127,10 @@ Dernière écriture - Taille + Taille + + + Nom Égal à @@ -160,7 +163,7 @@ Inférieure à - Source + Source ou Propriété Élément @@ -171,6 +174,9 @@ Destination + + Nom + Date et heure @@ -343,7 +349,10 @@ Voulez-vous continuer ? Date - Taille + Taille + + + Nom Est égal à diff --git a/src/ByteSync.Client/Assets/Resources/Resources.resx b/src/ByteSync.Client/Assets/Resources/Resources.resx index 06e92271..277a1fd5 100644 --- a/src/ByteSync.Client/Assets/Resources/Resources.resx +++ b/src/ByteSync.Client/Assets/Resources/Resources.resx @@ -237,6 +237,9 @@ Size + + Name + Equals @@ -265,7 +268,7 @@ Less than - Source + Source or Property Element @@ -276,6 +279,9 @@ Destination + + Name + Date and Time @@ -453,6 +459,9 @@ Would you like to continue ? Size + + Name + Equals diff --git a/src/ByteSync.Client/Business/Actions/Local/AtomicCondition.cs b/src/ByteSync.Client/Business/Actions/Local/AtomicCondition.cs index 548e97ca..53ca3863 100644 --- a/src/ByteSync.Client/Business/Actions/Local/AtomicCondition.cs +++ b/src/ByteSync.Client/Business/Actions/Local/AtomicCondition.cs @@ -11,24 +11,22 @@ public AtomicCondition() } - public AtomicCondition(DataPart source, ComparisonElement comparisonElement, ConditionOperatorTypes conditionOperator, DataPart? destination) + public AtomicCondition(DataPart source, ComparisonProperty comparisonProperty, ConditionOperatorTypes conditionOperator, DataPart? destination) { Source = source; - ComparisonElement = comparisonElement; + ComparisonProperty = comparisonProperty; ConditionOperator = conditionOperator; Destination = destination; } public DataPart Source { get; set; } = null!; - public ComparisonElement ComparisonElement { get; set; } + + public ComparisonProperty ComparisonProperty { get; set; } + public ConditionOperatorTypes ConditionOperator { get; set; } - - /// - /// Peut être nulle quand on travaille sur la Size ou la DateTime - /// + public DataPart? Destination { get; set; } - public string? SourceName { get @@ -46,6 +44,10 @@ public string? DestinationName } public int? Size { get; set; } + public SizeUnits? SizeUnit { get; set; } + public DateTime? DateTime { get; set; } + + public string? NamePattern { get; set; } } \ No newline at end of file diff --git a/src/ByteSync.Client/Business/Actions/Loose/LooseAtomicCondition.cs b/src/ByteSync.Client/Business/Actions/Loose/LooseAtomicCondition.cs index 7beb3fc3..e87d803b 100644 --- a/src/ByteSync.Client/Business/Actions/Loose/LooseAtomicCondition.cs +++ b/src/ByteSync.Client/Business/Actions/Loose/LooseAtomicCondition.cs @@ -12,11 +12,13 @@ public class LooseAtomicCondition : IAtomicCondition public int? Size { get; set; } - public ComparisonElement ComparisonElement { get; set; } + public ComparisonProperty ComparisonProperty { get; set; } public ConditionOperatorTypes ConditionOperator { get; set; } public DateTime? DateTime { get; set; } - + public SizeUnits? SizeUnit { get; set; } + + public string? NamePattern { get; set; } } \ No newline at end of file diff --git a/src/ByteSync.Client/Business/Comparisons/ComparisonElement.cs b/src/ByteSync.Client/Business/Comparisons/ComparisonProperty.cs similarity index 71% rename from src/ByteSync.Client/Business/Comparisons/ComparisonElement.cs rename to src/ByteSync.Client/Business/Comparisons/ComparisonProperty.cs index 1e452942..590f59cb 100644 --- a/src/ByteSync.Client/Business/Comparisons/ComparisonElement.cs +++ b/src/ByteSync.Client/Business/Comparisons/ComparisonProperty.cs @@ -1,9 +1,10 @@ namespace ByteSync.Business.Comparisons; -public enum ComparisonElement +public enum ComparisonProperty { Content = 1, Date = 2, Size = 3, Presence = 4, + Name = 5, } \ No newline at end of file diff --git a/src/ByteSync.Client/Business/Configurations/ApplicationSettings.cs b/src/ByteSync.Client/Business/Configurations/ApplicationSettings.cs index 30735bf6..5b918e74 100644 --- a/src/ByteSync.Client/Business/Configurations/ApplicationSettings.cs +++ b/src/ByteSync.Client/Business/Configurations/ApplicationSettings.cs @@ -24,6 +24,7 @@ public ApplicationSettings() AgreesBetaWarning0 = false; TrustedPublicKeys = null; SettingsVersion = null; + AcknowledgedAnnouncementIds = null; } public string InstallationId { get; set; } = null!; @@ -186,12 +187,39 @@ public ReadOnlyCollection? DecodedTrustedPublicKeys } public string? SettingsVersion { get; set; } + + public string? AcknowledgedAnnouncementIds { get; set; } private string EncryptionPassword { get; set; } = null!; [XmlIgnore] public RSA PrivateRsa { get; private set; } = null!; + [XmlIgnore] + public List DecodedAcknowledgedAnnouncementIds + { + get + { + if (AcknowledgedAnnouncementIds.IsNullOrEmpty()) + { + return new List(); + } + + return AcknowledgedAnnouncementIds!.Split(';', StringSplitOptions.RemoveEmptyEntries).ToList(); + } + set + { + if (value == null || value.Count == 0) + { + AcknowledgedAnnouncementIds = null; + } + else + { + AcknowledgedAnnouncementIds = string.Join(";", value); + } + } + } + public object Clone() { return this.MemberwiseClone(); @@ -238,6 +266,27 @@ public void RemoveTrustedKey(TrustedPublicKey trustedPublicKey) TrustedPublicKeys = CryptographyUtils.Encrypt(json, EncryptionPassword); } + public void InitializeAcknowledgedAnnouncementIds() + { + AcknowledgedAnnouncementIds = null; + } + + public void AddAcknowledgedAnnouncementId(string announcementId) + { + var acknowledgedIds = DecodedAcknowledgedAnnouncementIds; + + if (!acknowledgedIds.Contains(announcementId)) + { + acknowledgedIds.Add(announcementId); + DecodedAcknowledgedAnnouncementIds = acknowledgedIds; + } + } + + public bool IsAnnouncementAcknowledged(string announcementId) + { + return DecodedAcknowledgedAnnouncementIds.Contains(announcementId); + } + public void InitializeRsa() { var rsa = RSA.Create(); diff --git a/src/ByteSync.Client/ByteSync.Client.csproj b/src/ByteSync.Client/ByteSync.Client.csproj index e60cf2fe..d5dff11e 100644 --- a/src/ByteSync.Client/ByteSync.Client.csproj +++ b/src/ByteSync.Client/ByteSync.Client.csproj @@ -227,5 +227,9 @@ SessionSettingsEditView.axaml Code + + AnnouncementView.axaml + Code + diff --git a/src/ByteSync.Client/DependencyInjection/Modules/ViewModelsModule.cs b/src/ByteSync.Client/DependencyInjection/Modules/ViewModelsModule.cs index 3db08d8c..1bdbe646 100644 --- a/src/ByteSync.Client/DependencyInjection/Modules/ViewModelsModule.cs +++ b/src/ByteSync.Client/DependencyInjection/Modules/ViewModelsModule.cs @@ -2,6 +2,7 @@ using ByteSync.Business.Navigations; using ByteSync.Interfaces.Dialogs; using ByteSync.ViewModels; +using ByteSync.ViewModels.Announcements; using ByteSync.ViewModels.Headers; using ByteSync.ViewModels.Home; using ByteSync.ViewModels.Lobbies; @@ -28,6 +29,7 @@ protected override void Load(ContainerBuilder builder) .AsImplementedInterfaces(); builder.RegisterType().SingleInstance().AsSelf(); + builder.RegisterType().SingleInstance().AsSelf(); builder.RegisterType().Keyed(NavigationPanel.Home); builder.RegisterType().Keyed(NavigationPanel.CloudSynchronization); diff --git a/src/ByteSync.Client/DependencyInjection/Modules/ViewsModule.cs b/src/ByteSync.Client/DependencyInjection/Modules/ViewsModule.cs index 6cc74319..bd76760e 100644 --- a/src/ByteSync.Client/DependencyInjection/Modules/ViewsModule.cs +++ b/src/ByteSync.Client/DependencyInjection/Modules/ViewsModule.cs @@ -4,8 +4,10 @@ using ByteSync.ViewModels.Home; using ByteSync.ViewModels.Lobbies; using ByteSync.ViewModels.Sessions; +using ByteSync.ViewModels.Announcements; using ByteSync.Views; using ByteSync.Views.Home; +using ByteSync.Views.Announcements; using ByteSync.Views.Lobbies; using ByteSync.Views.Sessions; using ReactiveUI; @@ -26,6 +28,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As>(); builder.RegisterType().As>(); builder.RegisterType().As>(); + builder.RegisterType().As>(); builder.RegisterInstance(new AvaloniaActivationForViewFetcher()) .As() diff --git a/src/ByteSync.Client/Helpers/EnvironmentServiceExtensions.cs b/src/ByteSync.Client/Helpers/EnvironmentServiceExtensions.cs new file mode 100644 index 00000000..a64fd40c --- /dev/null +++ b/src/ByteSync.Client/Helpers/EnvironmentServiceExtensions.cs @@ -0,0 +1,21 @@ +using ByteSync.Common.Business.Misc; +using ByteSync.Interfaces.Controls.Applications; + +namespace ByteSync.Helpers; + +public static class EnvironmentServiceExtensions +{ + public static bool IsInstalledFromWindowsStore(this IEnvironmentService environmentService) + { + if (environmentService.OSPlatform == OSPlatforms.Windows) + { + if (environmentService.AssemblyFullName.Contains("\\Program Files\\WindowsApps\\") || + environmentService.AssemblyFullName.Contains("\\Program Files (x86)\\WindowsApps\\")) + { + return true; + } + } + + return false; + } +} diff --git a/src/ByteSync.Client/Interfaces/Announcements/IAnnouncementService.cs b/src/ByteSync.Client/Interfaces/Announcements/IAnnouncementService.cs new file mode 100644 index 00000000..cb12dfb3 --- /dev/null +++ b/src/ByteSync.Client/Interfaces/Announcements/IAnnouncementService.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace ByteSync.Interfaces.Announcements; + +public interface IAnnouncementService +{ + Task Start(); +} diff --git a/src/ByteSync.Client/Interfaces/Business/Actions/IAtomicCondition.cs b/src/ByteSync.Client/Interfaces/Business/Actions/IAtomicCondition.cs index e3f45550..975be1ef 100644 --- a/src/ByteSync.Client/Interfaces/Business/Actions/IAtomicCondition.cs +++ b/src/ByteSync.Client/Interfaces/Business/Actions/IAtomicCondition.cs @@ -11,11 +11,13 @@ public interface IAtomicCondition public int? Size { get; set; } - public ComparisonElement ComparisonElement { get; set; } + public ComparisonProperty ComparisonProperty { get; set; } public ConditionOperatorTypes ConditionOperator { get; set; } public DateTime? DateTime { get; set; } - + public SizeUnits? SizeUnit { get; set; } + + public string? NamePattern { get; set; } } \ No newline at end of file diff --git a/src/ByteSync.Client/Interfaces/Controls/Communications/Http/IAnnouncementApiClient.cs b/src/ByteSync.Client/Interfaces/Controls/Communications/Http/IAnnouncementApiClient.cs new file mode 100644 index 00000000..01a56458 --- /dev/null +++ b/src/ByteSync.Client/Interfaces/Controls/Communications/Http/IAnnouncementApiClient.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ByteSync.Common.Business.Announcements; + +namespace ByteSync.Interfaces.Controls.Communications.Http; + +public interface IAnnouncementApiClient +{ + Task> GetAnnouncements(); +} diff --git a/src/ByteSync.Client/Interfaces/Repositories/IAnnouncementRepository.cs b/src/ByteSync.Client/Interfaces/Repositories/IAnnouncementRepository.cs new file mode 100644 index 00000000..a143ca44 --- /dev/null +++ b/src/ByteSync.Client/Interfaces/Repositories/IAnnouncementRepository.cs @@ -0,0 +1,7 @@ +using ByteSync.Common.Business.Announcements; + +namespace ByteSync.Interfaces.Repositories; + +public interface IAnnouncementRepository : IBaseSourceCacheRepository +{ +} diff --git a/src/ByteSync.Client/Repositories/AnnouncementRepository.cs b/src/ByteSync.Client/Repositories/AnnouncementRepository.cs new file mode 100644 index 00000000..28f8e577 --- /dev/null +++ b/src/ByteSync.Client/Repositories/AnnouncementRepository.cs @@ -0,0 +1,9 @@ +using ByteSync.Common.Business.Announcements; +using ByteSync.Interfaces.Repositories; + +namespace ByteSync.Repositories; + +public class AnnouncementRepository : BaseSourceCacheRepository, IAnnouncementRepository +{ + protected override string KeySelector(Announcement announcement) => announcement.Id; +} diff --git a/src/ByteSync.Client/Services/Announcements/AnnouncementService.cs b/src/ByteSync.Client/Services/Announcements/AnnouncementService.cs new file mode 100644 index 00000000..145a0aff --- /dev/null +++ b/src/ByteSync.Client/Services/Announcements/AnnouncementService.cs @@ -0,0 +1,81 @@ +using System.Threading; +using ByteSync.Interfaces.Announcements; +using ByteSync.Interfaces.Controls.Communications.Http; +using ByteSync.Interfaces.Repositories; +using Microsoft.Extensions.Logging; + +namespace ByteSync.Services.Announcements; + +public class AnnouncementService : IAnnouncementService, IDisposable +{ + private readonly IAnnouncementApiClient _apiClient; + private readonly IAnnouncementRepository _repository; + private readonly ILogger _logger; + private CancellationTokenSource? _refreshCancellationTokenSource; + + protected virtual TimeSpan RefreshDelay => TimeSpan.FromHours(2); + + public AnnouncementService(IAnnouncementApiClient apiClient, IAnnouncementRepository repository, + ILogger logger) + { + _apiClient = apiClient; + _repository = repository; + _logger = logger; + } + + public async Task Start() + { + await RefreshAnnouncements(); + + _refreshCancellationTokenSource = new CancellationTokenSource(); + var token = _refreshCancellationTokenSource.Token; + + _ = Task.Run(async () => + { + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(RefreshDelay, token); + if (token.IsCancellationRequested) + { + break; + } + await RefreshAnnouncements(); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while refreshing announcements"); + } + } + }, token); + } + + private async Task RefreshAnnouncements() + { + try + { + var announcements = await _apiClient.GetAnnouncements(); + _repository.Clear(); + _repository.AddOrUpdate(announcements); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while loading announcements"); + } + } + + public void Dispose() + { + if (_refreshCancellationTokenSource != null) + { + _refreshCancellationTokenSource.Cancel(); + _refreshCancellationTokenSource.Dispose(); + _refreshCancellationTokenSource = null; + } + } +} diff --git a/src/ByteSync.Client/Services/Bootstrappers/GraphicalUserInterfaceBootstrapper.cs b/src/ByteSync.Client/Services/Bootstrappers/GraphicalUserInterfaceBootstrapper.cs index d6d36f13..40f12d1c 100644 --- a/src/ByteSync.Client/Services/Bootstrappers/GraphicalUserInterfaceBootstrapper.cs +++ b/src/ByteSync.Client/Services/Bootstrappers/GraphicalUserInterfaceBootstrapper.cs @@ -7,6 +7,7 @@ using ByteSync.Interfaces.Controls.Bootstrapping; using ByteSync.Interfaces.Controls.Navigations; using ByteSync.Interfaces.Controls.Themes; +using ByteSync.Interfaces.Announcements; using ByteSync.Interfaces.Services.Communications; using ByteSync.Interfaces.Updates; using Splat.Autofac; @@ -65,6 +66,12 @@ public override void Start() // LocalizationService _localizationService.Initialize(); + using (var scope = ContainerProvider.Container.BeginLifetimeScope()) + { + var announcementService = scope.Resolve(); + _ = announcementService.Start(); + } + diff --git a/src/ByteSync.Client/Services/Communications/Api/AnnouncementApiClient.cs b/src/ByteSync.Client/Services/Communications/Api/AnnouncementApiClient.cs new file mode 100644 index 00000000..fb02f122 --- /dev/null +++ b/src/ByteSync.Client/Services/Communications/Api/AnnouncementApiClient.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ByteSync.Common.Business.Announcements; +using ByteSync.Interfaces.Controls.Communications.Http; + +namespace ByteSync.Services.Communications.Api; + +public class AnnouncementApiClient : IAnnouncementApiClient +{ + private readonly IApiInvoker _apiInvoker; + private readonly ILogger _logger; + + public AnnouncementApiClient(IApiInvoker apiInvoker, ILogger logger) + { + _apiInvoker = apiInvoker; + _logger = logger; + } + + public async Task> GetAnnouncements() + { + try + { + return await _apiInvoker.GetAsync>("announcements"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while retrieving announcements"); + throw; + } + } +} diff --git a/src/ByteSync.Client/Services/Comparisons/DescriptionBuilders/AtomicConditionDescriptionBuilder.cs b/src/ByteSync.Client/Services/Comparisons/DescriptionBuilders/AtomicConditionDescriptionBuilder.cs index 74d6d1be..71ee51c5 100644 --- a/src/ByteSync.Client/Services/Comparisons/DescriptionBuilders/AtomicConditionDescriptionBuilder.cs +++ b/src/ByteSync.Client/Services/Comparisons/DescriptionBuilders/AtomicConditionDescriptionBuilder.cs @@ -35,6 +35,10 @@ public override void AppendDescription(StringBuilder stringBuilder, IAtomicCondi { stringBuilder.Append($"{atomicCondition.DateTime:g}"); } + else if (atomicCondition.NamePattern != null) + { + stringBuilder.Append($"{atomicCondition.NamePattern}"); + } } } @@ -42,25 +46,28 @@ private string GetComparisonElement(IAtomicCondition atomicCondition) { var result = ""; - switch (atomicCondition.ComparisonElement) + switch (atomicCondition.ComparisonProperty) { - case ComparisonElement.Content: + case ComparisonProperty.Content: result = LocalizationService[nameof(Resources.AtomicConditionDescription_ComparisonElement_Content)]; break; - case ComparisonElement.Date: + case ComparisonProperty.Date: result = LocalizationService[nameof(Resources.AtomicConditionDescription_ComparisonElement_Date)]; break; - case ComparisonElement.Size: + case ComparisonProperty.Size: result = LocalizationService[nameof(Resources.AtomicConditionDescription_ComparisonElement_Size)]; break; - case ComparisonElement.Presence: + case ComparisonProperty.Presence: result = LocalizationService[nameof(Resources.AtomicConditionDescription_ComparisonElement_Presence)]; break; + case ComparisonProperty.Name: + result = LocalizationService[nameof(Resources.AtomicConditionDescription_ComparisonElement_Name)]; + break; } if (result.IsEmpty()) { - throw new ApplicationException("Unknown atomicCondition.ComparisonElement " + atomicCondition.ComparisonElement); + throw new ApplicationException("Unknown atomicCondition.ComparisonElement " + atomicCondition.ComparisonProperty); } diff --git a/src/ByteSync.Client/Services/Comparisons/SynchronizationRuleMatcher.cs b/src/ByteSync.Client/Services/Comparisons/SynchronizationRuleMatcher.cs index cf3c43aa..0dcb6201 100644 --- a/src/ByteSync.Client/Services/Comparisons/SynchronizationRuleMatcher.cs +++ b/src/ByteSync.Client/Services/Comparisons/SynchronizationRuleMatcher.cs @@ -5,10 +5,11 @@ using ByteSync.Interfaces.Controls.Synchronizations; using ByteSync.Interfaces.Repositories; using ByteSync.Models.Comparisons.Result; +using System.Text.RegularExpressions; namespace ByteSync.Services.Comparisons; -class SynchronizationRuleMatcher : ISynchronizationRuleMatcher +public class SynchronizationRuleMatcher : ISynchronizationRuleMatcher { private readonly IAtomicActionConsistencyChecker _atomicActionConsistencyChecker; private readonly IAtomicActionRepository _atomicActionRepository; @@ -113,16 +114,18 @@ private bool ConditionsMatch(SynchronizationRule synchronizationRule, Comparison private bool ConditionMatches(AtomicCondition condition, ComparisonItem comparisonItem) { - switch (condition.ComparisonElement) + switch (condition.ComparisonProperty) { - case ComparisonElement.Content: + case ComparisonProperty.Content: return ConditionMatchesContent(condition, comparisonItem); - case ComparisonElement.Size: + case ComparisonProperty.Size: return ConditionMatchesSize(condition, comparisonItem); - case ComparisonElement.Date: + case ComparisonProperty.Date: return ConditionMatchesDate(condition, comparisonItem); - case ComparisonElement.Presence: + case ComparisonProperty.Presence: return ConditionMatchesPresence(condition, comparisonItem); + case ComparisonProperty.Name: + return ConditionMatchesName(condition, comparisonItem); default: return false; } @@ -276,10 +279,9 @@ private bool ConditionMatchesDate(AtomicCondition condition, ComparisonItem comp } else { - lastWriteTimeDestination = condition.DateTime!; + lastWriteTimeDestination = condition.DateTime!.Value.ToUniversalTime(); - if (lastWriteTimeSource != null && - lastWriteTimeDestination.Value.Second == 0 && lastWriteTimeDestination.Value.Millisecond == 0) + if (lastWriteTimeSource is { Second: 0, Millisecond: 0 }) { lastWriteTimeSource = lastWriteTimeSource.Value.Trim(TimeSpan.TicksPerMinute); } @@ -357,6 +359,41 @@ private bool ConditionMatchesPresence(AtomicCondition condition, ComparisonItem return result.Value; } + private bool ConditionMatchesName(AtomicCondition condition, ComparisonItem comparisonItem) + { + if (string.IsNullOrWhiteSpace(condition.NamePattern)) + { + return false; + } + + var name = comparisonItem.PathIdentity.FileName; + var pattern = condition.NamePattern!; + + bool result = false; + + if (pattern.Contains("*") && + condition.ConditionOperator.In(ConditionOperatorTypes.Equals, ConditionOperatorTypes.NotEquals)) + { + var regex = "^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$"; + var isMatch = Regex.IsMatch(name, regex, RegexOptions.IgnoreCase); + result = condition.ConditionOperator == ConditionOperatorTypes.Equals ? isMatch : !isMatch; + } + else + { + switch (condition.ConditionOperator) + { + case ConditionOperatorTypes.Equals: + result = string.Equals(name, pattern, StringComparison.OrdinalIgnoreCase); + break; + case ConditionOperatorTypes.NotEquals: + result = !string.Equals(name, pattern, StringComparison.OrdinalIgnoreCase); + break; + } + } + + return result; + } + private ContentIdentity? LocalizeContentIdentity(DataPart dataPart, ComparisonItem comparisonItem) { if (dataPart.Inventory != null) diff --git a/src/ByteSync.Client/Services/Configurations/ApplicationSettingsRepository.cs b/src/ByteSync.Client/Services/Configurations/ApplicationSettingsRepository.cs index 52ef23f3..909878bc 100644 --- a/src/ByteSync.Client/Services/Configurations/ApplicationSettingsRepository.cs +++ b/src/ByteSync.Client/Services/Configurations/ApplicationSettingsRepository.cs @@ -18,7 +18,7 @@ class ApplicationSettingsRepository : IApplicationSettingsRepository private ProductSerialDescription? _productSerialDescription; private string? _encryptionPassword; - public const string APPLICATION_SETTINGS_LAST_FORMAT_VERSION = "1.5"; + public const string APPLICATION_SETTINGS_LAST_FORMAT_VERSION = "1.6"; public ApplicationSettingsRepository(ILocalApplicationDataManager localApplicationDataManager, @@ -173,6 +173,12 @@ private bool CheckFormatVersion(ApplicationSettings applicationSettings) applicationSettings.InitializeTrustedPublicKeys(); } + // Initialize acknowledged announcement IDs if not present + if (string.IsNullOrEmpty(applicationSettings.AcknowledgedAnnouncementIds)) + { + applicationSettings.InitializeAcknowledgedAnnouncementIds(); + } + applicationSettings.SettingsVersion = APPLICATION_SETTINGS_LAST_FORMAT_VERSION; needUpdate = true; @@ -245,6 +251,7 @@ private ApplicationSettings PrepareApplicationSettings() applicationSettings.InitializeRsa(); applicationSettings.InitializeTrustedPublicKeys(); + applicationSettings.InitializeAcknowledgedAnnouncementIds(); CheckRsa(applicationSettings); diff --git a/src/ByteSync.Client/Services/Profiles/SynchronizationRulesConverter.cs b/src/ByteSync.Client/Services/Profiles/SynchronizationRulesConverter.cs index ca088edf..c27a1b3f 100644 --- a/src/ByteSync.Client/Services/Profiles/SynchronizationRulesConverter.cs +++ b/src/ByteSync.Client/Services/Profiles/SynchronizationRulesConverter.cs @@ -35,10 +35,11 @@ public List ConvertLooseSynchronizationRules( looseAtomicCondition.SourceName = condition.Source.Name; looseAtomicCondition.DestinationName = condition.Destination?.Name; looseAtomicCondition.Size = condition.Size; - looseAtomicCondition.ComparisonElement = condition.ComparisonElement; + looseAtomicCondition.ComparisonProperty = condition.ComparisonProperty; looseAtomicCondition.ConditionOperator = condition.ConditionOperator; looseAtomicCondition.DateTime = condition.DateTime; looseAtomicCondition.SizeUnit = condition.SizeUnit; + looseAtomicCondition.NamePattern = condition.NamePattern; looseSynchronizationRule.Conditions.Add(looseAtomicCondition); } @@ -123,10 +124,11 @@ public List ConvertToSynchronizationRuleVie atomicCondition.Source = _dataPartIndexer.GetDataPart(condition.SourceName)!; atomicCondition.Destination = _dataPartIndexer.GetDataPart(condition.DestinationName); atomicCondition.Size = condition.Size; - atomicCondition.ComparisonElement = condition.ComparisonElement; + atomicCondition.ComparisonProperty = condition.ComparisonProperty; atomicCondition.ConditionOperator = condition.ConditionOperator; atomicCondition.DateTime = condition.DateTime; atomicCondition.SizeUnit = condition.SizeUnit; + atomicCondition.NamePattern = condition.NamePattern; synchronizationRule.Conditions.Add(atomicCondition); } diff --git a/src/ByteSync.Client/Services/Sessions/Connecting/AfterJoinSessionService.cs b/src/ByteSync.Client/Services/Sessions/Connecting/AfterJoinSessionService.cs index 33164887..3fbda1f9 100644 --- a/src/ByteSync.Client/Services/Sessions/Connecting/AfterJoinSessionService.cs +++ b/src/ByteSync.Client/Services/Sessions/Connecting/AfterJoinSessionService.cs @@ -152,52 +152,52 @@ private async Task FillPathItems(AfterJoinSessionRequest request, List()); - - _logger.LogInformation("UpdateSystem: Application is installed from store, update check is disabled"); - - return; + _logger.LogInformation("UpdateSystem: Application is installed from store, auto-update is disabled"); } var updates = await _availableUpdatesLister.GetAvailableUpdates(); @@ -80,23 +80,6 @@ public async Task SearchNextAvailableVersionsAsync() } } - public bool IsApplicationInstalledFromStore - { - get - { - if (_environmentService.OSPlatform == OSPlatforms.Windows) - { - if (_environmentService.AssemblyFullName.Contains("\\Program Files\\WindowsApps\\") - || _environmentService.AssemblyFullName.Contains("\\Program Files (x86)\\WindowsApps\\")) - { - return true; - } - } - - return false; - } - } - private List DeduplicateVersions(List nextAvailableVersions) { var deduplicatedVersions = new List(); diff --git a/src/ByteSync.Client/ViewModels/Announcements/AnnouncementViewModel.cs b/src/ByteSync.Client/ViewModels/Announcements/AnnouncementViewModel.cs new file mode 100644 index 00000000..8cd38f8e --- /dev/null +++ b/src/ByteSync.Client/ViewModels/Announcements/AnnouncementViewModel.cs @@ -0,0 +1,93 @@ +using System.Collections.ObjectModel; +using System.Reactive; +using System.Reactive.Disposables; +using ByteSync.Interfaces; +using ByteSync.Interfaces.Repositories; +using ReactiveUI; +using ReactiveUI.Fody.Helpers; + +namespace ByteSync.ViewModels.Announcements; + +public class AnnouncementViewModel : ActivatableViewModelBase +{ + private readonly IAnnouncementRepository _announcementRepository; + private readonly ILocalizationService _localizationService; + private readonly IApplicationSettingsRepository _applicationSettingsRepository; + + public AnnouncementViewModel() + { + Announcements = new ObservableCollection(); + } + + public AnnouncementViewModel(IAnnouncementRepository announcementRepository, + ILocalizationService localizationService, + IApplicationSettingsRepository applicationSettingsRepository) : this() + { + _announcementRepository = announcementRepository; + _localizationService = localizationService; + _applicationSettingsRepository = applicationSettingsRepository; + + AcknowledgeAnnouncementCommand = ReactiveCommand.Create(AcknowledgeAnnouncement); + + this.WhenActivated(disposables => + { + _announcementRepository.ObservableCache + .Connect() + .Subscribe(_ => Refresh()) + .DisposeWith(disposables); + + _localizationService.CurrentCultureObservable + .Subscribe(_ => Refresh()) + .DisposeWith(disposables); + }); + } + + public ObservableCollection Announcements { get; } + + public ReactiveCommand AcknowledgeAnnouncementCommand { get; } + + [Reactive] + public bool IsVisible { get; private set; } + + private void Refresh() + { + var cultureCode = _localizationService.CurrentCultureDefinition.Code; + var applicationSettings = _applicationSettingsRepository.GetCurrentApplicationSettings(); + var acknowledgedIds = applicationSettings.DecodedAcknowledgedAnnouncementIds; + + var unacknowledgedAnnouncements = _announcementRepository.Elements + .Where(a => !acknowledgedIds.Contains(a.Id)) + .Select(a => new AnnouncementItemViewModel + { + Id = a.Id, + Message = a.Message.TryGetValue(cultureCode, out var msg) + ? msg + : a.Message.Values.FirstOrDefault() ?? string.Empty + }) + .ToList(); + + Announcements.Clear(); + foreach (var announcement in unacknowledgedAnnouncements) + { + Announcements.Add(announcement); + } + + IsVisible = Announcements.Count > 0; + } + + private void AcknowledgeAnnouncement(string announcementId) + { + _applicationSettingsRepository.UpdateCurrentApplicationSettings(settings => + { + settings.AddAcknowledgedAnnouncementId(announcementId); + }, true); + + Refresh(); + } +} + +public class AnnouncementItemViewModel +{ + public string Id { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; +} diff --git a/src/ByteSync.Client/ViewModels/Headers/UpdateDetailsViewModel.cs b/src/ByteSync.Client/ViewModels/Headers/UpdateDetailsViewModel.cs index 2fc00eb4..f0b48b65 100644 --- a/src/ByteSync.Client/ViewModels/Headers/UpdateDetailsViewModel.cs +++ b/src/ByteSync.Client/ViewModels/Headers/UpdateDetailsViewModel.cs @@ -20,6 +20,7 @@ using DynamicData.Binding; using ReactiveUI; using ReactiveUI.Fody.Helpers; +using ByteSync.Helpers; namespace ByteSync.ViewModels.Headers; @@ -115,6 +116,11 @@ public bool CanAutoUpdate { get { + if (_environmentService.IsInstalledFromWindowsStore()) + { + return false; + } + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || (_environmentService.IsPortableApplication && RuntimeInformation.IsOSPlatform(OSPlatform.Linux)); } diff --git a/src/ByteSync.Client/ViewModels/MainWindowViewModel.cs b/src/ByteSync.Client/ViewModels/MainWindowViewModel.cs index d79f667e..a04c73cb 100644 --- a/src/ByteSync.Client/ViewModels/MainWindowViewModel.cs +++ b/src/ByteSync.Client/ViewModels/MainWindowViewModel.cs @@ -12,6 +12,7 @@ using ByteSync.Interfaces.Repositories; using ByteSync.Interfaces.Services.Sessions; using ByteSync.Interfaces.Services.Sessions.Connecting; +using ByteSync.ViewModels.Announcements; using ByteSync.ViewModels.Headers; using ByteSync.ViewModels.Misc; using ReactiveUI; @@ -38,8 +39,9 @@ public MainWindowViewModel() } - public MainWindowViewModel(ISessionService sessionService, ICloudSessionConnectionService cloudSessionConnectionService, INavigationService navigationService, - IZoomService zoomService, FlyoutContainerViewModel? flyoutContainerViewModel, HeaderViewModel headerViewModel, + public MainWindowViewModel(ISessionService sessionService, ICloudSessionConnectionService cloudSessionConnectionService, INavigationService navigationService, + IZoomService zoomService, FlyoutContainerViewModel? flyoutContainerViewModel, HeaderViewModel headerViewModel, + AnnouncementViewModel announcementViewModel, IIndex navigationPanelViewModels, IMessageBoxViewModelFactory messageBoxViewModelFactory, IQuitSessionService quitSessionService, ICloudSessionConnectionRepository cloudSessionConnectionRepository, ILogger logger) @@ -58,9 +60,12 @@ public MainWindowViewModel(ISessionService sessionService, ICloudSessionConnecti FlyoutContainer = flyoutContainerViewModel!; Header = headerViewModel; + Announcement = announcementViewModel; this.WhenActivated(disposables => { + announcementViewModel.Activator.Activate(); + _zoomService.ZoomLevel .Select(zoomLevel => (1d / 100) * zoomLevel) .ToPropertyEx(this, x => x.ZoomLevel) @@ -98,6 +103,9 @@ public MainWindowViewModel(ISessionService sessionService, ICloudSessionConnecti [Reactive] public ViewModelBase Header { get; set; } + [Reactive] + public AnnouncementViewModel Announcement { get; set; } + [Reactive] public FlyoutContainerViewModel FlyoutContainer { get; set; } diff --git a/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Actions/AtomicConditionEditViewModel.cs b/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Actions/AtomicConditionEditViewModel.cs index 0de45762..45bdf5f9 100644 --- a/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Actions/AtomicConditionEditViewModel.cs +++ b/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Actions/AtomicConditionEditViewModel.cs @@ -4,7 +4,6 @@ using ByteSync.Business.Actions.Local; using ByteSync.Business.Comparisons; using ByteSync.Common.Business.Inventories; -using ByteSync.Common.Helpers; using ByteSync.Interfaces.Services.Sessions; using ByteSync.Services.Converters; using ByteSync.ViewModels.Sessions.Comparisons.Actions.Misc; @@ -31,22 +30,23 @@ public AtomicConditionEditViewModel(FileSystemTypes fileSystemType, IDataPartInd FileSystemType = fileSystemType; - ConditionSources = new ObservableCollection(); - ComparisonElements = new ObservableCollection(); + SourceOrProperties = new ObservableCollection(); + ComparisonProperties = new ObservableCollection(); ComparisonOperators = new ObservableCollection(); ConditionDestinations = new ObservableCollection(); SizeUnits = new ObservableCollection(); + NamePattern = string.Empty; SelectedDateTime = DateTime.Now; SelectedTime = SelectedDateTime.Value.TimeOfDay; RemoveCommand = ReactiveCommand.Create(Remove); - var canSwapSides = this.WhenAnyValue(x => x.SelectedSource, x => x.SelectedDestination, - (source, destination) => source != null && destination != null); + var canSwapSides = this.WhenAnyValue(x => x.SelectedSourceOrProperty, x => x.SelectedDestination, + (source, destination) => source is { IsDataPart: true } && destination is { IsVirtual: false }); SwapSidesCommand = ReactiveCommand.Create(SwapSides, canSwapSides); - FillConditionSourcesData(); + FillSourceOrPropertiesData(); FillConditionSourceTypes(); @@ -56,8 +56,13 @@ public AtomicConditionEditViewModel(FileSystemTypes fileSystemType, IDataPartInd FillSizeUnits(); - this.WhenAnyValue(x => x.SelectedSource) - .Subscribe(_ => FillAvailableOperators()); + this.WhenAnyValue(x => x.SelectedSourceOrProperty) + .Subscribe(_ => + { + FillAvailableOperators(); + FillAvailableDestinations(); + ShowHideControls(); + }); this.WhenAnyValue(x => x.SelectedComparisonElement) .Subscribe(_ => @@ -84,9 +89,9 @@ public AtomicConditionEditViewModel(FileSystemTypes fileSystemType, IDataPartInd public FileSystemTypes FileSystemType { get; } - internal ObservableCollection ConditionSources { get; set; } + internal ObservableCollection SourceOrProperties { get; set; } - internal ObservableCollection ComparisonElements { get; set; } + internal ObservableCollection ComparisonProperties { get; set; } internal ObservableCollection ComparisonOperators { get; set; } @@ -97,10 +102,10 @@ public AtomicConditionEditViewModel(FileSystemTypes fileSystemType, IDataPartInd internal ObservableCollection SizeUnits { get; set; } [Reactive] - internal DataPart? SelectedSource { get; set; } + internal SourceOrPropertyViewModel? SelectedSourceOrProperty { get; set; } [Reactive] - internal ComparisonElementViewModel? SelectedComparisonElement { get; set; } + internal ComparisonPropertyViewModel? SelectedComparisonElement { get; set; } [Reactive] internal ConditionOperatorViewModel? SelectedComparisonOperator { get; set; } @@ -120,64 +125,85 @@ public AtomicConditionEditViewModel(FileSystemTypes fileSystemType, IDataPartInd [Reactive] internal SizeUnitViewModel SelectedSizeUnit { get; set; } + [Reactive] + public string? NamePattern { get; set; } + + [Reactive] + public bool IsNameVisible { get; set; } + [Reactive] public bool IsDateVisible { get; set; } [Reactive] public bool IsSizeVisible { get; set; } + [Reactive] + public bool IsSourceTypeComboBoxVisible { get; set; } + + [Reactive] + public bool IsDotVisible { get; set; } + + [Reactive] + public bool IsDestinationComboBoxVisible { get; set; } + public ReactiveCommand RemoveCommand { get; set; } public ReactiveCommand SwapSidesCommand { get; set; } - private void FillConditionSourcesData() + private void FillSourceOrPropertiesData() { - ConditionSources.Clear(); - ConditionSources.AddAll(_dataPartIndexer.GetAllDataParts()); + SourceOrProperties.Clear(); + + // Add sources (DataPart) + SourceOrProperties.AddAll(_dataPartIndexer.GetAllDataParts().Select(dp => new SourceOrPropertyViewModel(dp))); + + // Add the Name property + var nameProperty = new SourceOrPropertyViewModel(ComparisonProperty.Name, Resources.AtomicConditionEdit_Name); + SourceOrProperties.Add(nameProperty); } private void FillConditionSourceTypes() { - ComparisonElementViewModel comparisonElementView; + ComparisonPropertyViewModel comparisonPropertyView; if (FileSystemType == FileSystemTypes.File) { - comparisonElementView = new ComparisonElementViewModel + comparisonPropertyView = new ComparisonPropertyViewModel { - ComparisonElement = ComparisonElement.Content, + ComparisonProperty = ComparisonProperty.Content, Description = Resources.AtomicConditionEdit_Content }; - ComparisonElements.Add(comparisonElementView); + ComparisonProperties.Add(comparisonPropertyView); - comparisonElementView = new ComparisonElementViewModel + comparisonPropertyView = new ComparisonPropertyViewModel { - ComparisonElement = ComparisonElement.Date, + ComparisonProperty = ComparisonProperty.Date, Description = Resources.AtomicConditionEdit_LastWriteTime }; - ComparisonElements.Add(comparisonElementView); + ComparisonProperties.Add(comparisonPropertyView); - comparisonElementView = new ComparisonElementViewModel + comparisonPropertyView = new ComparisonPropertyViewModel { - ComparisonElement = ComparisonElement.Size, + ComparisonProperty = ComparisonProperty.Size, Description = Resources.AtomicConditionEdit_Size }; - ComparisonElements.Add(comparisonElementView); - - comparisonElementView = new ComparisonElementViewModel + ComparisonProperties.Add(comparisonPropertyView); + + comparisonPropertyView = new ComparisonPropertyViewModel { - ComparisonElement = ComparisonElement.Presence, + ComparisonProperty = ComparisonProperty.Presence, Description = Resources.AtomicConditionEdit_Presence }; - ComparisonElements.Add(comparisonElementView); + ComparisonProperties.Add(comparisonPropertyView); } else { - comparisonElementView = new ComparisonElementViewModel + comparisonPropertyView = new ComparisonPropertyViewModel { - ComparisonElement = ComparisonElement.Presence, + ComparisonProperty = ComparisonProperty.Presence, Description = Resources.AtomicConditionEdit_Presence }; - ComparisonElements.Add(comparisonElementView); + ComparisonProperties.Add(comparisonPropertyView); } } @@ -189,8 +215,8 @@ private void FillAvailableOperators() if (FileSystemType == FileSystemTypes.File) { - if (SelectedSource == null || SelectedComparisonElement == null || - SelectedComparisonElement.ComparisonElement == ComparisonElement.Content) + if (SelectedSourceOrProperty?.IsDataPart == true && (SelectedComparisonElement == null || + SelectedComparisonElement.ComparisonProperty == ComparisonProperty.Content)) { var conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.Equals); ComparisonOperators.Add(conditionOperatorView); @@ -198,7 +224,7 @@ private void FillAvailableOperators() conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.NotEquals); ComparisonOperators.Add(conditionOperatorView); } - else if (SelectedComparisonElement.ComparisonElement == ComparisonElement.Date) + else if (SelectedSourceOrProperty?.IsDataPart == true && SelectedComparisonElement?.ComparisonProperty == ComparisonProperty.Date) { var conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.Equals); ComparisonOperators.Add(conditionOperatorView); @@ -212,7 +238,7 @@ private void FillAvailableOperators() conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.IsNewerThan); ComparisonOperators.Add(conditionOperatorView); } - else if (SelectedComparisonElement.ComparisonElement == ComparisonElement.Size) + else if (SelectedSourceOrProperty?.IsDataPart == true && SelectedComparisonElement?.ComparisonProperty == ComparisonProperty.Size) { var conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.Equals); ComparisonOperators.Add(conditionOperatorView); @@ -226,7 +252,15 @@ private void FillAvailableOperators() conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.IsSmallerThan); ComparisonOperators.Add(conditionOperatorView); } - else if (SelectedComparisonElement.ComparisonElement == ComparisonElement.Presence) + else if (SelectedSourceOrProperty?.IsDataPart == true && SelectedComparisonElement?.ComparisonProperty == ComparisonProperty.Name) + { + var conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.Equals); + ComparisonOperators.Add(conditionOperatorView); + + conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.NotEquals); + ComparisonOperators.Add(conditionOperatorView); + } + else if (SelectedSourceOrProperty?.IsDataPart == true && SelectedComparisonElement?.ComparisonProperty == ComparisonProperty.Presence) { var conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.ExistsOn); ComparisonOperators.Add(conditionOperatorView); @@ -234,11 +268,19 @@ private void FillAvailableOperators() conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.NotExistsOn); ComparisonOperators.Add(conditionOperatorView); } + else if (SelectedSourceOrProperty?.IsNameProperty == true) + { + var conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.Equals); + ComparisonOperators.Add(conditionOperatorView); + + conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.NotEquals); + ComparisonOperators.Add(conditionOperatorView); + } } else if (FileSystemType == FileSystemTypes.Directory) { - if (SelectedSource == null || SelectedComparisonElement == null || - SelectedComparisonElement.ComparisonElement == ComparisonElement.Presence) + if (SelectedSourceOrProperty?.IsDataPart == true && (SelectedComparisonElement == null || + SelectedComparisonElement.ComparisonProperty == ComparisonProperty.Presence)) { var conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.ExistsOn); ComparisonOperators.Add(conditionOperatorView); @@ -246,14 +288,28 @@ private void FillAvailableOperators() conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.NotExistsOn); ComparisonOperators.Add(conditionOperatorView); } + else if (SelectedSourceOrProperty?.IsDataPart == true && SelectedComparisonElement?.ComparisonProperty == ComparisonProperty.Name) + { + var conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.Equals); + ComparisonOperators.Add(conditionOperatorView); + + conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.NotEquals); + ComparisonOperators.Add(conditionOperatorView); + } + else if (SelectedSourceOrProperty?.IsNameProperty == true) + { + var conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.Equals); + ComparisonOperators.Add(conditionOperatorView); + + conditionOperatorView = BuildConditionOperatorView(ConditionOperatorTypes.NotEquals); + ComparisonOperators.Add(conditionOperatorView); + } } else { throw new ApplicationException("Unknown FileSystemType " + FileSystemType); } - - if (selectedOperator != null) { SelectedComparisonOperator = ComparisonOperators.FirstOrDefault(ao => ao.Equals(selectedOperator)); @@ -266,14 +322,35 @@ private void FillAvailableDestinations() ConditionDestinations.Clear(); - ConditionDestinations.AddAll(ConditionSources); + bool addCustomDestination = false; + bool selectCustomDestination = false; + + if (SelectedSourceOrProperty?.IsProperty == true) + { + addCustomDestination = true; + selectCustomDestination = true; + } + else + { + ConditionDestinations.AddAll(_dataPartIndexer.GetAllDataParts()); - if (SelectedComparisonElement is { IsDateOrSize: true } - && SelectedComparisonOperator != null - && !SelectedComparisonOperator.ConditionOperator.In(ConditionOperatorTypes.ExistsOn, ConditionOperatorTypes.NotExistsOn)) + if (SelectedComparisonElement is { IsDateOrSize: true } + && SelectedComparisonOperator != null + && !SelectedComparisonOperator.ConditionOperator.In(ConditionOperatorTypes.ExistsOn, ConditionOperatorTypes.NotExistsOn)) + { + addCustomDestination = true; + } + } + + if (addCustomDestination) { var conditionData = new DataPart(Resources.AtomicConditionEdit_Custom); ConditionDestinations.Add(conditionData); + + if (selectCustomDestination) + { + selectedDestination = conditionData; + } } if (selectedDestination != null) @@ -338,26 +415,55 @@ private ConditionOperatorViewModel BuildConditionOperatorView(ConditionOperatorT private void ShowHideControls() { - IsDateVisible = SelectedDestination is { IsVirtual: true } - && SelectedComparisonElement is { ComparisonElement: ComparisonElement.Date }; - IsSizeVisible = SelectedDestination is { IsVirtual: true } - && SelectedComparisonElement is { ComparisonElement: ComparisonElement.Size }; + // Control the visibility of the dot and the SourceTypeComboBox + IsSourceTypeComboBoxVisible = SelectedSourceOrProperty?.IsDataPart == true; + IsDotVisible = SelectedSourceOrProperty?.IsDataPart == true; + + // Control the visibility of the DestinationComboBox + IsDestinationComboBoxVisible = SelectedSourceOrProperty?.IsNameProperty != true; + + // Control the visibility of input fields + IsDateVisible = SelectedDestination is { IsVirtual: true } + && SelectedSourceOrProperty?.IsDataPart == true && SelectedComparisonElement is { ComparisonProperty: ComparisonProperty.Date }; + IsSizeVisible = SelectedDestination is { IsVirtual: true } + && SelectedSourceOrProperty?.IsDataPart == true && SelectedComparisonElement is { ComparisonProperty: ComparisonProperty.Size }; + IsNameVisible = SelectedSourceOrProperty?.IsNameProperty == true; } internal AtomicCondition? ExportAtomicCondition() { - if (SelectedSource == null || SelectedComparisonElement == null || SelectedComparisonOperator == null) + if (SelectedSourceOrProperty == null || SelectedComparisonOperator == null) { return null; } + // If a property is selected (like Name), use a default source + DataPart source; + ComparisonProperty comparisonProperty; + + if (SelectedSourceOrProperty.IsProperty) + { + // For properties, use the first available source as the default source + source = _dataPartIndexer.GetAllDataParts().First(); + comparisonProperty = SelectedSourceOrProperty.ComparisonProperty!.Value; + } + else + { + source = SelectedSourceOrProperty.DataPart!; + comparisonProperty = SelectedComparisonElement?.ComparisonProperty ?? ComparisonProperty.Content; + } + if (SelectedDestination == null || SelectedDestination.IsVirtual) { - if (SelectedComparisonElement.ComparisonElement == ComparisonElement.Size && SelectedSize == null) + if (comparisonProperty == ComparisonProperty.Size && SelectedSize == null) { return null; } - if (SelectedComparisonElement.ComparisonElement == ComparisonElement.Date && SelectedDateTime == null) + if (comparisonProperty == ComparisonProperty.Date && SelectedDateTime == null) + { + return null; + } + if (comparisonProperty == ComparisonProperty.Name && NamePattern.IsNullOrEmpty()) { return null; } @@ -369,10 +475,10 @@ private void ShowHideControls() selectedDestination = null; } - var atomicCondition = new AtomicCondition(SelectedSource, SelectedComparisonElement.ComparisonElement, + var atomicCondition = new AtomicCondition(source, comparisonProperty, SelectedComparisonOperator.ConditionOperator, selectedDestination); - if (selectedDestination == null && SelectedSize != null) + if (selectedDestination == null && comparisonProperty == ComparisonProperty.Size && SelectedSize != null) { atomicCondition.Size = SelectedSize; atomicCondition.SizeUnit = SelectedSizeUnit.SizeUnit; @@ -383,27 +489,51 @@ private void ShowHideControls() atomicCondition.SizeUnit = null; } - if (selectedDestination == null && SelectedDateTime != null) + if (selectedDestination == null && comparisonProperty == ComparisonProperty.Date && SelectedDateTime != null) { var localDateTime = new DateTime( SelectedDateTime.Value.Year, SelectedDateTime.Value.Month, SelectedDateTime.Value.Day, SelectedTime.Hours, SelectedTime.Minutes, 0, DateTimeKind.Local); - atomicCondition.DateTime = localDateTime.ToUniversalTime(); + atomicCondition.DateTime = localDateTime; } else { atomicCondition.DateTime = null; } + if (NamePattern.IsNotEmpty() && comparisonProperty == ComparisonProperty.Name) + { + atomicCondition.NamePattern = NamePattern; + } + else + { + atomicCondition.NamePattern = null; + } + return atomicCondition; } internal void SetAtomicCondition(AtomicCondition atomicCondition) { - SelectedSource = atomicCondition.Source; + // Find the corresponding source or property + if (atomicCondition.ComparisonProperty == ComparisonProperty.Name) + { + // If it is a condition on the name, select the Name property + SelectedSourceOrProperty = SourceOrProperties.FirstOrDefault(sop => sop.IsNameProperty); + } + else + { + // Otherwise, select the corresponding source + SelectedSourceOrProperty = SourceOrProperties.FirstOrDefault(sop => + sop.IsDataPart && sop.DataPart?.Name == atomicCondition.Source.Name); + } - SelectedComparisonElement = ComparisonElements.FirstOrDefault(ce => Equals(ce.ComparisonElement, atomicCondition.ComparisonElement)); + if (SelectedSourceOrProperty?.IsDataPart == true) + { + SelectedComparisonElement = ComparisonProperties.FirstOrDefault(ce => Equals(ce.ComparisonProperty, atomicCondition.ComparisonProperty)); + } + SelectedComparisonOperator = ComparisonOperators.FirstOrDefault(co => Equals(co.ConditionOperator, atomicCondition.ConditionOperator)); if (atomicCondition.Destination == null) @@ -426,8 +556,10 @@ internal void SetAtomicCondition(AtomicCondition atomicCondition) { SelectedDateTime = DateTime.Now; } - + SelectedTime = SelectedDateTime.Value.TimeOfDay; + + NamePattern = atomicCondition.NamePattern; } private void Remove() @@ -437,10 +569,15 @@ private void Remove() private void SwapSides() { - var source = SelectedSource; + var source = SelectedSourceOrProperty; var destination = SelectedDestination; - SelectedSource = destination; - SelectedDestination = source; + // Currently, swapping with properties is not allowed + // because it would require more complex logic + if (source?.IsDataPart == true && destination is { IsVirtual: false }) + { + SelectedSourceOrProperty = new SourceOrPropertyViewModel(destination); + SelectedDestination = source?.DataPart; + } } } \ No newline at end of file diff --git a/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Actions/Misc/ComparisonElementViewModel.cs b/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Actions/Misc/ComparisonPropertyViewModel.cs similarity index 60% rename from src/ByteSync.Client/ViewModels/Sessions/Comparisons/Actions/Misc/ComparisonElementViewModel.cs rename to src/ByteSync.Client/ViewModels/Sessions/Comparisons/Actions/Misc/ComparisonPropertyViewModel.cs index e2a27d8c..a5ba7deb 100644 --- a/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Actions/Misc/ComparisonElementViewModel.cs +++ b/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Actions/Misc/ComparisonPropertyViewModel.cs @@ -3,16 +3,16 @@ namespace ByteSync.ViewModels.Sessions.Comparisons.Actions.Misc; -class ComparisonElementViewModel : BindableBase +class ComparisonPropertyViewModel : BindableBase { private string _description; - public ComparisonElementViewModel() + public ComparisonPropertyViewModel() { } - public ComparisonElement ComparisonElement { get; set; } + public ComparisonProperty ComparisonProperty { get; set; } public string Description { @@ -27,7 +27,7 @@ public bool IsDateOrSize { get { - return ComparisonElement == ComparisonElement.Date || ComparisonElement == ComparisonElement.Size; + return ComparisonProperty == ComparisonProperty.Date || ComparisonProperty == ComparisonProperty.Size; } } } \ No newline at end of file diff --git a/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Actions/Misc/SourceOrPropertyViewModel.cs b/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Actions/Misc/SourceOrPropertyViewModel.cs new file mode 100644 index 00000000..1b28875e --- /dev/null +++ b/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Actions/Misc/SourceOrPropertyViewModel.cs @@ -0,0 +1,89 @@ +using ByteSync.Business.Comparisons; +using Prism.Mvvm; + +namespace ByteSync.ViewModels.Sessions.Comparisons.Actions.Misc; + +public class SourceOrPropertyViewModel : BindableBase +{ + private string _displayName; + private string _description; + + public SourceOrPropertyViewModel() + { + } + + public SourceOrPropertyViewModel(DataPart dataPart) + { + DataPart = dataPart; + IsDataPart = true; + DisplayName = dataPart.Name; + Description = dataPart.Name; + } + + public SourceOrPropertyViewModel(ComparisonProperty comparisonProperty, string description) + { + ComparisonProperty = comparisonProperty; + IsDataPart = false; + DisplayName = description; + Description = description; + } + + public DataPart? DataPart { get; set; } + public ComparisonProperty? ComparisonProperty { get; set; } + + public bool IsDataPart { get; set; } + public bool IsProperty => !IsDataPart; + + public string DisplayName + { + get => _displayName; + set => SetProperty(ref _displayName, value); + } + + public string Description + { + get => _description; + set => SetProperty(ref _description, value); + } + + public bool IsNameProperty + { + get => IsProperty && ComparisonProperty == Business.Comparisons.ComparisonProperty.Name; + } + + protected bool Equals(SourceOrPropertyViewModel other) + { + if (IsDataPart != other.IsDataPart) + return false; + + if (IsDataPart) + { + return Equals(DataPart, other.DataPart); + } + else + { + return ComparisonProperty == other.ComparisonProperty; + } + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((SourceOrPropertyViewModel)obj); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = IsDataPart.GetHashCode(); + if (IsDataPart) + hashCode = (hashCode * 397) ^ (DataPart != null ? DataPart.GetHashCode() : 0); + else + hashCode = (hashCode * 397) ^ (ComparisonProperty != null ? ComparisonProperty.GetHashCode() : 0); + return hashCode; + } + } +} \ No newline at end of file diff --git a/src/ByteSync.Client/Views/Announcements/AnnouncementView.axaml b/src/ByteSync.Client/Views/Announcements/AnnouncementView.axaml new file mode 100644 index 00000000..ff36a16f --- /dev/null +++ b/src/ByteSync.Client/Views/Announcements/AnnouncementView.axaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ByteSync.Client/Views/Announcements/AnnouncementView.axaml.cs b/src/ByteSync.Client/Views/Announcements/AnnouncementView.axaml.cs new file mode 100644 index 00000000..b2e01164 --- /dev/null +++ b/src/ByteSync.Client/Views/Announcements/AnnouncementView.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia.ReactiveUI; +using ByteSync.ViewModels.Announcements; +using ReactiveUI; + +namespace ByteSync.Views.Announcements; + +public partial class AnnouncementView : ReactiveUserControl +{ + public AnnouncementView() + { + InitializeComponent(); + this.WhenActivated(disposables => { }); + } +} diff --git a/src/ByteSync.Client/Views/MainWindow.axaml b/src/ByteSync.Client/Views/MainWindow.axaml index 9fb72576..3f8d787f 100644 --- a/src/ByteSync.Client/Views/MainWindow.axaml +++ b/src/ByteSync.Client/Views/MainWindow.axaml @@ -27,7 +27,7 @@ - + - + - + + + diff --git a/src/ByteSync.Client/Views/Sessions/Comparisons/Actions/AtomicConditionEditView.axaml b/src/ByteSync.Client/Views/Sessions/Comparisons/Actions/AtomicConditionEditView.axaml index 9a2f2a57..3db6cf00 100644 --- a/src/ByteSync.Client/Views/Sessions/Comparisons/Actions/AtomicConditionEditView.axaml +++ b/src/ByteSync.Client/Views/Sessions/Comparisons/Actions/AtomicConditionEditView.axaml @@ -32,24 +32,26 @@ + SelectedItem="{Binding Path=SelectedSourceOrProperty}"> - + - + + SelectedItem="{Binding Path=SelectedComparisonElement}" + IsVisible="{Binding Path=IsSourceTypeComboBoxVisible}"> @@ -74,9 +76,10 @@ + SelectedItem="{Binding Path=SelectedDestination}" + IsVisible="{Binding Path=IsDestinationComboBoxVisible}"> @@ -84,22 +87,26 @@ - - + - + - - + - + Message { get; set; } = new(); +} diff --git a/src/ByteSync.Common/Controls/Json/JsonSerializerOptionsHelper.cs b/src/ByteSync.Common/Controls/Json/JsonSerializerOptionsHelper.cs index 880d2ea0..15627b51 100644 --- a/src/ByteSync.Common/Controls/Json/JsonSerializerOptionsHelper.cs +++ b/src/ByteSync.Common/Controls/Json/JsonSerializerOptionsHelper.cs @@ -21,6 +21,8 @@ public static void SetOptions(JsonSerializerOptions options) options.Converters.Add(new JsonStringEnumConverter()); options.Converters.Add(new UtcDateTimeConverter()); + options.PropertyNameCaseInsensitive = true; + options.IgnoreReadOnlyProperties = true; options.IgnoreReadOnlyFields = true; } diff --git a/src/ByteSync.Functions/Helpers/Loaders/DependencyInjectionLoader.cs b/src/ByteSync.Functions/Helpers/Loaders/DependencyInjectionLoader.cs index e27aac30..87ad1639 100644 --- a/src/ByteSync.Functions/Helpers/Loaders/DependencyInjectionLoader.cs +++ b/src/ByteSync.Functions/Helpers/Loaders/DependencyInjectionLoader.cs @@ -37,6 +37,12 @@ private static void RegisterServerCommonAssembly(ContainerBuilder builder) .InstancePerLifetimeScope(); } + builder.Register(c => + { + var factory = c.Resolve(); + return factory.CreateClient(); + }).As().InstancePerLifetimeScope(); + builder.RegisterAssemblyTypes(executingAssembly) .Where(t => t.Name.EndsWith("Service")) .InstancePerLifetimeScope() diff --git a/src/ByteSync.Functions/Helpers/Middlewares/JwtMiddleware.cs b/src/ByteSync.Functions/Helpers/Middlewares/JwtMiddleware.cs index e5ddafda..8c9c5a4e 100644 --- a/src/ByteSync.Functions/Helpers/Middlewares/JwtMiddleware.cs +++ b/src/ByteSync.Functions/Helpers/Middlewares/JwtMiddleware.cs @@ -27,7 +27,8 @@ public class JwtMiddleware : IFunctionsWorkerMiddleware public JwtMiddleware(IOptions appSettings, IClientsRepository clientsRepository, ILogger logger) { var loginFunctionEntryPoint = GetEntryPoint(nameof(AuthFunction.Login)); - _allowedAnonymousFunctionEntryPoints = [loginFunctionEntryPoint]; + var getAnnouncementsFunctionEntryPoint = GetEntryPoint(nameof(AnnouncementFunction.GetAnnouncements)); + _allowedAnonymousFunctionEntryPoints = [loginFunctionEntryPoint, getAnnouncementsFunctionEntryPoint]; _secret = appSettings.Value.Secret; _clientsRepository = clientsRepository; diff --git a/src/ByteSync.Functions/Http/AnnouncementFunction.cs b/src/ByteSync.Functions/Http/AnnouncementFunction.cs new file mode 100644 index 00000000..83a85c9f --- /dev/null +++ b/src/ByteSync.Functions/Http/AnnouncementFunction.cs @@ -0,0 +1,29 @@ +using System.Net; +using ByteSync.ServerCommon.Commands.Announcements; +using MediatR; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; + +namespace ByteSync.Functions.Http; + +public class AnnouncementFunction +{ + private readonly IMediator _mediator; + + public AnnouncementFunction(IMediator mediator) + { + _mediator = mediator; + } + + [Function("GetAnnouncements")] + public async Task GetAnnouncements( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "announcements")] HttpRequestData req, + FunctionContext executionContext) + { + var announcements = await _mediator.Send(new GetActiveAnnouncementsRequest()); + + var response = req.CreateResponse(); + await response.WriteAsJsonAsync(announcements, HttpStatusCode.OK); + return response; + } +} diff --git a/src/ByteSync.Functions/Program.cs b/src/ByteSync.Functions/Program.cs index 108c294f..ad2c0d0a 100644 --- a/src/ByteSync.Functions/Program.cs +++ b/src/ByteSync.Functions/Program.cs @@ -51,6 +51,7 @@ { services.AddApplicationInsightsTelemetryWorkerService(); services.ConfigureFunctionsApplicationInsights(); + services.AddHttpClient(); services.Configure(options => { diff --git a/src/ByteSync.Functions/Timer/RefreshAnnouncementsFunction.cs b/src/ByteSync.Functions/Timer/RefreshAnnouncementsFunction.cs new file mode 100644 index 00000000..149a6775 --- /dev/null +++ b/src/ByteSync.Functions/Timer/RefreshAnnouncementsFunction.cs @@ -0,0 +1,40 @@ +using ByteSync.Common.Business.Announcements; +using ByteSync.ServerCommon.Interfaces.Loaders; +using ByteSync.ServerCommon.Interfaces.Repositories; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace ByteSync.Functions.Timer; + +public class RefreshAnnouncementsFunction +{ + private readonly IAnnouncementsLoader _loader; + private readonly IAnnouncementRepository _repository; + private readonly ILogger _logger; + + public RefreshAnnouncementsFunction(IAnnouncementsLoader loader, IAnnouncementRepository repository, + ILogger logger) + { + _loader = loader; + _repository = repository; + _logger = logger; + } + + [Function("RefreshAnnouncementsFunction")] + public async Task RunAsync([TimerTrigger("0 0 */2 * * *" +#if DEBUG + , RunOnStartup = true +#endif + )] TimerInfo timerInfo) + { + var currentUtcTime = DateTime.UtcNow; + _logger.LogInformation("Refreshing announcements at: {Now}", currentUtcTime); + + var announcements = await _loader.Load(); + var validAnnouncements = announcements.Where(d => d.EndDate > currentUtcTime).ToList(); + + await _repository.SaveAll(validAnnouncements); + + return validAnnouncements.Count; + } +} diff --git a/src/ByteSync.ServerCommon/Business/Settings/AppSettings.cs b/src/ByteSync.ServerCommon/Business/Settings/AppSettings.cs index 33ad6c71..25789135 100644 --- a/src/ByteSync.ServerCommon/Business/Settings/AppSettings.cs +++ b/src/ByteSync.ServerCommon/Business/Settings/AppSettings.cs @@ -7,6 +7,8 @@ public class AppSettings public int JwtDurationInSeconds { get; set; } = 3600; public bool SkipClientsVersionCheck { get; set; } = false; - + public string UpdatesDefinitionUrl { get; set; } = ""; + + public string AnnouncementsUrl { get; set; } = ""; } \ No newline at end of file diff --git a/src/ByteSync.ServerCommon/Commands/Announcements/GetActiveAnnouncementsCommandHandler.cs b/src/ByteSync.ServerCommon/Commands/Announcements/GetActiveAnnouncementsCommandHandler.cs new file mode 100644 index 00000000..4366c45a --- /dev/null +++ b/src/ByteSync.ServerCommon/Commands/Announcements/GetActiveAnnouncementsCommandHandler.cs @@ -0,0 +1,27 @@ +using ByteSync.Common.Business.Announcements; +using ByteSync.ServerCommon.Interfaces.Repositories; +using MediatR; + +namespace ByteSync.ServerCommon.Commands.Announcements; + +public class GetActiveAnnouncementsCommandHandler : IRequestHandler> +{ + private readonly IAnnouncementRepository _repository; + + public GetActiveAnnouncementsCommandHandler(IAnnouncementRepository repository) + { + _repository = repository; + } + + public async Task> Handle(GetActiveAnnouncementsRequest request, CancellationToken cancellationToken) + { + var allAnnouncements = await _repository.GetAll(); + if (allAnnouncements is null) + { + return new List(); + } + + var now = DateTime.UtcNow; + return allAnnouncements.Where(m => m.StartDate <= now && now < m.EndDate).ToList(); + } +} diff --git a/src/ByteSync.ServerCommon/Commands/Announcements/GetActiveAnnouncementsRequest.cs b/src/ByteSync.ServerCommon/Commands/Announcements/GetActiveAnnouncementsRequest.cs new file mode 100644 index 00000000..52435b6a --- /dev/null +++ b/src/ByteSync.ServerCommon/Commands/Announcements/GetActiveAnnouncementsRequest.cs @@ -0,0 +1,6 @@ +using ByteSync.Common.Business.Announcements; +using MediatR; + +namespace ByteSync.ServerCommon.Commands.Announcements; + +public class GetActiveAnnouncementsRequest : IRequest>; diff --git a/src/ByteSync.ServerCommon/Entities/EntityType.cs b/src/ByteSync.ServerCommon/Entities/EntityType.cs index a16fa3a3..0fc421f3 100644 --- a/src/ByteSync.ServerCommon/Entities/EntityType.cs +++ b/src/ByteSync.ServerCommon/Entities/EntityType.cs @@ -11,5 +11,6 @@ public enum EntityType Client, ClientSoftwareVersionSettings, CloudSessionProfile, - Lobby + Lobby, + Announcement } \ No newline at end of file diff --git a/src/ByteSync.ServerCommon/Factories/CacheKeyFactory.cs b/src/ByteSync.ServerCommon/Factories/CacheKeyFactory.cs index 46bb9592..3f752a3d 100644 --- a/src/ByteSync.ServerCommon/Factories/CacheKeyFactory.cs +++ b/src/ByteSync.ServerCommon/Factories/CacheKeyFactory.cs @@ -51,6 +51,7 @@ private string GetEntityTypeName(EntityType entityType) EntityType.ClientSoftwareVersionSettings => "ClientSoftwareVersionSettings", EntityType.CloudSessionProfile => "CloudSessionProfile", EntityType.Lobby => "Lobby", + EntityType.Announcement => "Announcement", _ => throw new ArgumentOutOfRangeException(nameof(entityType), entityType, null) }; } diff --git a/src/ByteSync.ServerCommon/Interfaces/Loaders/IAnnouncementsLoader.cs b/src/ByteSync.ServerCommon/Interfaces/Loaders/IAnnouncementsLoader.cs new file mode 100644 index 00000000..a33e9e52 --- /dev/null +++ b/src/ByteSync.ServerCommon/Interfaces/Loaders/IAnnouncementsLoader.cs @@ -0,0 +1,8 @@ +using ByteSync.Common.Business.Announcements; + +namespace ByteSync.ServerCommon.Interfaces.Loaders; + +public interface IAnnouncementsLoader +{ + Task> Load(); +} diff --git a/src/ByteSync.ServerCommon/Interfaces/Repositories/IAnnouncementRepository.cs b/src/ByteSync.ServerCommon/Interfaces/Repositories/IAnnouncementRepository.cs new file mode 100644 index 00000000..3a4d63c8 --- /dev/null +++ b/src/ByteSync.ServerCommon/Interfaces/Repositories/IAnnouncementRepository.cs @@ -0,0 +1,10 @@ +using ByteSync.Common.Business.Announcements; + +namespace ByteSync.ServerCommon.Interfaces.Repositories; + +public interface IAnnouncementRepository : IRepository> +{ + Task?> GetAll(); + + Task SaveAll(List announcements); +} diff --git a/src/ByteSync.ServerCommon/Loaders/AnnouncementsLoader.cs b/src/ByteSync.ServerCommon/Loaders/AnnouncementsLoader.cs new file mode 100644 index 00000000..fecfc8b9 --- /dev/null +++ b/src/ByteSync.ServerCommon/Loaders/AnnouncementsLoader.cs @@ -0,0 +1,50 @@ +using ByteSync.Common.Controls.Json; +using ByteSync.Common.Business.Announcements; +using ByteSync.ServerCommon.Business.Settings; +using ByteSync.ServerCommon.Interfaces.Loaders; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Polly; + +namespace ByteSync.ServerCommon.Loaders; + +public class AnnouncementsLoader : IAnnouncementsLoader +{ + private readonly AppSettings _appSettings; + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + public AnnouncementsLoader( + IOptions appSettings, + ILogger logger, + HttpClient httpClient) + { + _appSettings = appSettings.Value; + _logger = logger; + _httpClient = httpClient; + } + + public async Task> Load() + { + List? announcements = null; + + var policy = Policy + .Handle() + .WaitAndRetry(3, retryAttempt => TimeSpan.FromSeconds(3 * (retryAttempt + 1))); + + await policy.Execute(async () => + { + _logger.LogInformation("Loading announcements from {url}", _appSettings.AnnouncementsUrl); + var contents = await _httpClient.GetStringAsync(_appSettings.AnnouncementsUrl); + + announcements = JsonHelper.Deserialize>(contents); + }); + + if (announcements == null) + { + throw new Exception("Failed to load announcements"); + } + + return announcements!; + } +} diff --git a/src/ByteSync.ServerCommon/Loaders/ClientSoftwareVersionSettingsLoader.cs b/src/ByteSync.ServerCommon/Loaders/ClientSoftwareVersionSettingsLoader.cs index 2cf11466..361e2935 100644 --- a/src/ByteSync.ServerCommon/Loaders/ClientSoftwareVersionSettingsLoader.cs +++ b/src/ByteSync.ServerCommon/Loaders/ClientSoftwareVersionSettingsLoader.cs @@ -12,11 +12,16 @@ public class ClientSoftwareVersionSettingsLoader : IClientSoftwareVersionSetting { private readonly AppSettings _appSettings; private readonly ILogger _logger; + private readonly HttpClient _httpClient; - public ClientSoftwareVersionSettingsLoader(IOptions appSettings, ILogger logger) + public ClientSoftwareVersionSettingsLoader( + IOptions appSettings, + ILogger logger, + HttpClient httpClient) { _appSettings = appSettings.Value; _logger = logger; + _httpClient = httpClient; } public async Task Load() @@ -29,12 +34,8 @@ public async Task Load() await policy.Execute(async () => { - string contents; - using (var wc = new HttpClient()) - { - _logger.LogInformation("Loading minimal version from {url}", _appSettings.UpdatesDefinitionUrl); - contents = await wc.GetStringAsync(_appSettings.UpdatesDefinitionUrl); - } + _logger.LogInformation("Loading minimal version from {url}", _appSettings.UpdatesDefinitionUrl); + var contents = await _httpClient.GetStringAsync(_appSettings.UpdatesDefinitionUrl); var softwareUpdates = JsonHelper.Deserialize>(contents)!; diff --git a/src/ByteSync.ServerCommon/Repositories/AnnouncementRepository.cs b/src/ByteSync.ServerCommon/Repositories/AnnouncementRepository.cs new file mode 100644 index 00000000..f9e66919 --- /dev/null +++ b/src/ByteSync.ServerCommon/Repositories/AnnouncementRepository.cs @@ -0,0 +1,28 @@ +using ByteSync.Common.Business.Announcements; +using ByteSync.ServerCommon.Entities; +using ByteSync.ServerCommon.Interfaces.Repositories; +using ByteSync.ServerCommon.Interfaces.Services; + +namespace ByteSync.ServerCommon.Repositories; + +public class AnnouncementRepository : BaseRepository>, IAnnouncementRepository +{ + public const string UniqueKey = "All"; + + public AnnouncementRepository(IRedisInfrastructureService redisInfrastructureService, ICacheRepository> cacheRepository) + : base(redisInfrastructureService, cacheRepository) + { + } + + public override EntityType EntityType => EntityType.Announcement; + + public Task?> GetAll() + { + return Get(UniqueKey); + } + + public Task SaveAll(List announcements) + { + return Save(UniqueKey, announcements); + } +} diff --git a/tests/ByteSync.Client.Tests/Services/Announcements/AnnouncementServiceTests.cs b/tests/ByteSync.Client.Tests/Services/Announcements/AnnouncementServiceTests.cs new file mode 100644 index 00000000..c987894c --- /dev/null +++ b/tests/ByteSync.Client.Tests/Services/Announcements/AnnouncementServiceTests.cs @@ -0,0 +1,76 @@ +using ByteSync.Common.Business.Announcements; +using ByteSync.Interfaces.Controls.Communications.Http; +using ByteSync.Interfaces.Repositories; +using ByteSync.Services.Announcements; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; + +namespace ByteSync.Tests.Services.Announcements; + +[TestFixture] +public class AnnouncementServiceTests +{ + private Mock _apiClient = null!; + private Mock _repository = null!; + private Mock> _logger = null!; + + [SetUp] + public void SetUp() + { + _apiClient = new Mock(); + _repository = new Mock(); + _logger = new Mock>(); + } + + [Test] + public async Task Start_ShouldLoadAnnouncementsInitially() + { + // Arrange + var announcements = new List { new() { Id = "1" } }; + _apiClient.Setup(a => a.GetAnnouncements()).ReturnsAsync(announcements); + + using var service = new AnnouncementService(_apiClient.Object, _repository.Object, _logger.Object); + + // Act + await service.Start(); + service.Dispose(); + + // Assert + _apiClient.Verify(a => a.GetAnnouncements(), Times.Once); + _repository.Verify(r => r.Clear(), Times.Once); + _repository.Verify(r => r.AddOrUpdate(announcements), Times.Once); + } + + private class TestAnnouncementService : AnnouncementService + { + private readonly TimeSpan _delay; + public TestAnnouncementService(IAnnouncementApiClient apiClient, IAnnouncementRepository repository, ILogger logger, TimeSpan delay) + : base(apiClient, repository, logger) + { + _delay = delay; + } + + protected override TimeSpan RefreshDelay => _delay; + } + + [Test] + public async Task Start_ShouldRefreshAnnouncementsPeriodically() + { + // Arrange + var announcements = new List { new() { Id = "1" } }; + _apiClient.Setup(a => a.GetAnnouncements()).ReturnsAsync(announcements); + + using var service = new TestAnnouncementService(_apiClient.Object, _repository.Object, _logger.Object, TimeSpan.FromMilliseconds(50)); + + // Act + await service.Start(); + await Task.Delay(160); + service.Dispose(); + + // Assert + _apiClient.Verify(a => a.GetAnnouncements(), Times.AtLeast(2)); + _repository.Verify(r => r.Clear(), Times.AtLeast(2)); + _repository.Verify(r => r.AddOrUpdate(It.IsAny>()), Times.AtLeast(2)); + } +} diff --git a/tests/ByteSync.Client.Tests/Services/Communications/SearchUpdateServiceTests.cs b/tests/ByteSync.Client.Tests/Services/Communications/SearchUpdateServiceTests.cs index 90c3180b..83768471 100644 --- a/tests/ByteSync.Client.Tests/Services/Communications/SearchUpdateServiceTests.cs +++ b/tests/ByteSync.Client.Tests/Services/Communications/SearchUpdateServiceTests.cs @@ -154,27 +154,30 @@ public async Task SearchNextAvailableVersionsAsync_ShouldDeduplicateVersions() [Theory] [TestCase(@"C:\Program Files\WindowsApps\MyApp.exe")] [TestCase(@"C:\Program Files (x86)\WindowsApps\MyApp.exe")] - public async Task SearchNextAvailableVersionsAsync_WhenInstalledFromStore_ShouldUpdateWithEmptyList(string assemblyFullName) + public async Task SearchNextAvailableVersionsAsync_WhenInstalledFromStore_ShouldSearchForUpdates(string assemblyFullName) { // Arrange - _mockEnvironmentService.SetupGet(m => m.AssemblyFullName) - .Returns(@"C:\Program Files\WindowsApps\MyApp.exe"); - _mockEnvironmentService.SetupGet(m => m.OSPlatform) - .Returns(OSPlatforms.Windows); + var currentVersion = new Version("1.0.0"); + _mockEnvironmentService.SetupGet(m => m.ApplicationVersion).Returns(currentVersion); + _mockEnvironmentService.SetupGet(m => m.AssemblyFullName).Returns(assemblyFullName); + _mockEnvironmentService.SetupGet(m => m.OSPlatform).Returns(OSPlatforms.Windows); + + var availableUpdates = new List + { + CreateSoftwareVersion("1.1.0", PriorityLevel.Minimal) + }; + + _mockAvailableUpdatesLister.Setup(m => m.GetAvailableUpdates()) + .ReturnsAsync(availableUpdates); // Act await _searchUpdateService.SearchNextAvailableVersionsAsync(); // Assert - _mockAvailableUpdateRepository.Verify( - m => m.UpdateAvailableUpdates(It.Is>(list => list.Count == 0)), - Times.Once - ); - _mockAvailableUpdateRepository.Verify( - m => m.UpdateAvailableUpdates(It.IsAny>()), Times.Once); - _mockAvailableUpdateRepository.Verify( - m => m.Clear(), Times.Never); - _mockAvailableUpdatesLister.Verify(m => m.GetAvailableUpdates(), Times.Never); + _mockAvailableUpdateRepository.Verify(m => m.UpdateAvailableUpdates(It.Is>( + list => list.Count == 1 && list[0].Version == "1.1.0")), Times.Once); + _mockAvailableUpdateRepository.Verify(m => m.Clear(), Times.Never); + _mockAvailableUpdatesLister.Verify(m => m.GetAvailableUpdates(), Times.Once); } private SoftwareVersion CreateSoftwareVersion(string version, PriorityLevel level) diff --git a/tests/ByteSync.Client.Tests/Services/Comparisons/SynchronizationRuleMatcherNameTests.cs b/tests/ByteSync.Client.Tests/Services/Comparisons/SynchronizationRuleMatcherNameTests.cs new file mode 100644 index 00000000..b1284f9d --- /dev/null +++ b/tests/ByteSync.Client.Tests/Services/Comparisons/SynchronizationRuleMatcherNameTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Reflection; +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Business.Inventories; +using ByteSync.Common.Business.Inventories; +using ByteSync.Interfaces.Controls.Comparisons; +using ByteSync.Interfaces.Repositories; +using ByteSync.Models.Comparisons.Result; +using ByteSync.Services.Comparisons; +using FluentAssertions; +using Moq; +using NUnit.Framework; + +namespace ByteSync.Tests.Services.Comparisons; + +[TestFixture] +public class SynchronizationRuleMatcherNameTests +{ + [TestCase("file.txt", "file.txt", ConditionOperatorTypes.Equals, true)] + [TestCase("file.txt", "other.txt", ConditionOperatorTypes.Equals, false)] + [TestCase("file.txt", "*.txt", ConditionOperatorTypes.Equals, true)] + [TestCase("file.txt", "*.doc", ConditionOperatorTypes.Equals, false)] + [TestCase("file.txt", "*.txt", ConditionOperatorTypes.NotEquals, false)] + [TestCase("file.txt", "*.doc", ConditionOperatorTypes.NotEquals, true)] + public void ConditionMatchesName_ShouldBehaveAsExpected(string name, string pattern, ConditionOperatorTypes op, bool expected) + { + var matcher = new SynchronizationRuleMatcher(new Mock().Object, + new Mock().Object); + + var condition = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Name, + ConditionOperator = op, + NamePattern = pattern + }; + + var pathIdentity = new PathIdentity(FileSystemTypes.File, name, name, name); + var item = new ComparisonItem(pathIdentity); + + var method = typeof(SynchronizationRuleMatcher) + .GetMethod("ConditionMatchesName", BindingFlags.NonPublic | BindingFlags.Instance)!; + var result = (bool)method.Invoke(matcher, new object[] { condition, item })!; + result.Should().Be(expected); + } +} diff --git a/tests/ByteSync.Client.Tests/ViewModels/Announcements/AnnouncementViewModelTests.cs b/tests/ByteSync.Client.Tests/ViewModels/Announcements/AnnouncementViewModelTests.cs new file mode 100644 index 00000000..f235f683 --- /dev/null +++ b/tests/ByteSync.Client.Tests/ViewModels/Announcements/AnnouncementViewModelTests.cs @@ -0,0 +1,740 @@ +using System.Reactive.Linq; +using System.Reactive.Subjects; +using ByteSync.Business; +using ByteSync.Business.Configurations; +using ByteSync.Common.Business.Announcements; +using ByteSync.Interfaces; +using ByteSync.Interfaces.Repositories; +using ByteSync.TestsCommon; +using ByteSync.ViewModels.Announcements; +using DynamicData; +using FluentAssertions; +using Moq; +using NUnit.Framework; + +namespace ByteSync.Tests.ViewModels.Announcements; + +[TestFixture] +public class AnnouncementViewModelTests : AbstractTester +{ + private Mock _mockAnnouncementRepository = null!; + private Mock _mockLocalizationService = null!; + private Mock _mockApplicationSettingsRepository = null!; + private AnnouncementViewModel _announcementViewModel = null!; + private Subject _cultureSubject = null!; + private ApplicationSettings _applicationSettings = null!; + + [SetUp] + public void SetUp() + { + _mockAnnouncementRepository = new Mock(); + _mockLocalizationService = new Mock(); + _mockApplicationSettingsRepository = new Mock(); + _cultureSubject = new Subject(); + _applicationSettings = new ApplicationSettings(); + + // Setup default mocks + _mockLocalizationService.Setup(x => x.CurrentCultureDefinition) + .Returns(new CultureDefinition { Code = "en" }); + _mockLocalizationService.Setup(x => x.CurrentCultureObservable) + .Returns(_cultureSubject.AsObservable()); + _mockApplicationSettingsRepository.Setup(x => x.GetCurrentApplicationSettings()) + .Returns(_applicationSettings); + + // Setup ObservableCache mock to avoid NullReferenceException + var mockObservableCache = new Mock>(); + mockObservableCache + .Setup(x => x.Connect(It.IsAny>(), It.IsAny())) + .Returns(Observable.Empty>()); + _mockAnnouncementRepository.Setup(x => x.ObservableCache) + .Returns(mockObservableCache.Object); + + _announcementViewModel = new AnnouncementViewModel( + _mockAnnouncementRepository.Object, + _mockLocalizationService.Object, + _mockApplicationSettingsRepository.Object + ); + } + + [Test] + public void Constructor_ShouldInitializeProperties() + { + // Assert + _announcementViewModel.Announcements.Should().NotBeNull(); + _announcementViewModel.AcknowledgeAnnouncementCommand.Should().NotBeNull(); + _announcementViewModel.IsVisible.Should().BeFalse(); + } + + [Test] + public void Refresh_WithNoAnnouncements_ShouldSetIsVisibleToFalse() + { + // Arrange + _mockAnnouncementRepository.Setup(x => x.Elements) + .Returns(new List()); + + // Act + _announcementViewModel.Activator.Activate(); + + // Assert + _announcementViewModel.IsVisible.Should().BeFalse(); + _announcementViewModel.Announcements.Should().BeEmpty(); + } + + [Test] + public void Refresh_WithUnacknowledgedAnnouncements_ShouldAddToCollection() + { + // Arrange + var announcements = new List + { + new() + { + Id = "1", + Message = new Dictionary { { "en", "Test message" } } + }, + new() + { + Id = "2", + Message = new Dictionary { { "en", "Test message 2" } } + } + }; + + // Setup ObservableCache to emit the announcements + var mockObservableCache = new Mock>(); + var changeSet = new ChangeSet(); + changeSet.Add(new Change(ChangeReason.Add, "1", announcements[0])); + changeSet.Add(new Change(ChangeReason.Add, "2", announcements[1])); + + mockObservableCache + .Setup(x => x.Connect(It.IsAny>(), It.IsAny())) + .Returns(Observable.Return(changeSet)); + + _mockAnnouncementRepository.Setup(x => x.ObservableCache) + .Returns(mockObservableCache.Object); + + // Setup Elements to return the announcements + _mockAnnouncementRepository.Setup(x => x.Elements) + .Returns(announcements); + + _applicationSettings.DecodedAcknowledgedAnnouncementIds = []; + + // Act + _announcementViewModel.Activator.Activate(); + + // Assert + _announcementViewModel.Announcements.Should().HaveCount(2); + _announcementViewModel.IsVisible.Should().BeTrue(); + _announcementViewModel.Announcements[0].Id.Should().Be("1"); + _announcementViewModel.Announcements[0].Message.Should().Be("Test message"); + _announcementViewModel.Announcements[1].Id.Should().Be("2"); + _announcementViewModel.Announcements[1].Message.Should().Be("Test message 2"); + } + + [Test] + public void Refresh_WithAcknowledgedAnnouncements_ShouldNotAddToCollection() + { + // Arrange + var announcements = new List + { + new() + { + Id = "1", + Message = new Dictionary { { "en", "Test message" } } + }, + new() + { + Id = "2", + Message = new Dictionary { { "en", "Test message 2" } } + } + }; + + // Setup ObservableCache to emit the announcements + var mockObservableCache = new Mock>(); + var changeSet = new ChangeSet(); + changeSet.Add(new Change(ChangeReason.Add, "1", announcements[0])); + changeSet.Add(new Change(ChangeReason.Add, "2", announcements[1])); + + mockObservableCache + .Setup(x => x.Connect(It.IsAny>(), It.IsAny())) + .Returns(Observable.Return(changeSet)); + + _mockAnnouncementRepository.Setup(x => x.ObservableCache) + .Returns(mockObservableCache.Object); + + // Setup Elements to return the announcements + _mockAnnouncementRepository.Setup(x => x.Elements) + .Returns(announcements); + + _applicationSettings.DecodedAcknowledgedAnnouncementIds = ["1", "2"]; + + // Act + _announcementViewModel.Activator.Activate(); + + // Assert + _announcementViewModel.Announcements.Should().HaveCount(0); + _announcementViewModel.IsVisible.Should().BeFalse(); + } + + [Test] + public void Refresh_WithMixedAnnouncements_ShouldOnlyAddUnacknowledged() + { + // Arrange + var announcements = new List + { + new() + { + Id = "1", + Message = new Dictionary { { "en", "Test message 1" } } + }, + new() + { + Id = "2", + Message = new Dictionary { { "en", "Test message 2" } } + }, + new() + { + Id = "3", + Message = new Dictionary { { "en", "Test message 3" } } + } + }; + + // Setup ObservableCache to emit the announcements + var mockObservableCache = new Mock>(); + var changeSet = new ChangeSet(); + changeSet.Add(new Change(ChangeReason.Add, "1", announcements[0])); + changeSet.Add(new Change(ChangeReason.Add, "2", announcements[1])); + changeSet.Add(new Change(ChangeReason.Add, "3", announcements[2])); + + mockObservableCache + .Setup(x => x.Connect(It.IsAny>(), It.IsAny())) + .Returns(Observable.Return(changeSet)); + + _mockAnnouncementRepository.Setup(x => x.ObservableCache) + .Returns(mockObservableCache.Object); + + // Setup Elements to return the announcements + _mockAnnouncementRepository.Setup(x => x.Elements) + .Returns(announcements); + + _applicationSettings.DecodedAcknowledgedAnnouncementIds = ["2"]; + + // Act + _announcementViewModel.Activator.Activate(); + + // Assert + _announcementViewModel.Announcements.Should().HaveCount(2); + _announcementViewModel.IsVisible.Should().BeTrue(); + _announcementViewModel.Announcements[0].Id.Should().Be("1"); + _announcementViewModel.Announcements[0].Message.Should().Be("Test message 1"); + _announcementViewModel.Announcements[1].Id.Should().Be("3"); + _announcementViewModel.Announcements[1].Message.Should().Be("Test message 3"); + } + + [Test] + public void Refresh_WithLocalizedMessage_ShouldUseCorrectLanguage() + { + // Arrange + var announcements = new List + { + new() + { + Id = "1", + Message = new Dictionary + { + { "en", "English message" }, + { "fr", "Message français" } + } + } + }; + + // Setup ObservableCache to emit the announcements + var mockObservableCache = new Mock>(); + var changeSet = new ChangeSet(); + changeSet.Add(new Change(ChangeReason.Add, "1", announcements[0])); + + mockObservableCache + .Setup(x => x.Connect(It.IsAny>(), It.IsAny())) + .Returns(Observable.Return(changeSet)); + + _mockAnnouncementRepository.Setup(x => x.ObservableCache) + .Returns(mockObservableCache.Object); + + // Setup Elements to return the announcements + _mockAnnouncementRepository.Setup(x => x.Elements) + .Returns(announcements); + + // Ensure the announcement is not acknowledged + _applicationSettings.DecodedAcknowledgedAnnouncementIds = []; + + _mockLocalizationService.Setup(x => x.CurrentCultureDefinition) + .Returns(new CultureDefinition { Code = "fr" }); + + // Act + _announcementViewModel.Activator.Activate(); + + // Assert + _announcementViewModel.Announcements.Should().HaveCount(1); + _announcementViewModel.Announcements[0].Message.Should().Be("Message français"); + } + + [Test] + public void Refresh_WithMissingLocalizedMessage_ShouldUseFirstAvailableMessage() + { + // Arrange + var announcements = new List + { + new() + { + Id = "1", + Message = new Dictionary + { + { "en", "English message" }, + { "de", "Deutsche Nachricht" } + } + } + }; + + // Setup ObservableCache to emit the announcements + var mockObservableCache = new Mock>(); + var changeSet = new ChangeSet(); + changeSet.Add(new Change(ChangeReason.Add, "1", announcements[0])); + + mockObservableCache + .Setup(x => x.Connect(It.IsAny>(), It.IsAny())) + .Returns(Observable.Return(changeSet)); + + _mockAnnouncementRepository.Setup(x => x.ObservableCache) + .Returns(mockObservableCache.Object); + + // Setup Elements to return the announcements + _mockAnnouncementRepository.Setup(x => x.Elements) + .Returns(announcements); + + // Ensure the announcement is not acknowledged + _applicationSettings.DecodedAcknowledgedAnnouncementIds = []; + + _mockLocalizationService.Setup(x => x.CurrentCultureDefinition) + .Returns(new CultureDefinition { Code = "fr" }); // French not available + + // Act + _announcementViewModel.Activator.Activate(); + + // Assert + _announcementViewModel.Announcements.Should().HaveCount(1); + _announcementViewModel.Announcements[0].Message.Should().Be("English message"); // Should use first available + } + + [Test] + public void Refresh_WithEmptyMessage_ShouldUseEmptyString() + { + // Arrange + var announcements = new List + { + new() + { + Id = "1", + Message = new Dictionary + { + { "en", "" }, + { "fr", "Message français" } + } + } + }; + + // Setup ObservableCache to emit the announcements + var mockObservableCache = new Mock>(); + var changeSet = new ChangeSet(); + changeSet.Add(new Change(ChangeReason.Add, "1", announcements[0])); + + mockObservableCache + .Setup(x => x.Connect(It.IsAny>(), It.IsAny())) + .Returns(Observable.Return(changeSet)); + + _mockAnnouncementRepository.Setup(x => x.ObservableCache) + .Returns(mockObservableCache.Object); + + // Setup Elements to return the announcements + _mockAnnouncementRepository.Setup(x => x.Elements) + .Returns(announcements); + + // Ensure the announcement is not acknowledged + _applicationSettings.DecodedAcknowledgedAnnouncementIds = []; + + // Act + _announcementViewModel.Activator.Activate(); + + // Assert + _announcementViewModel.Announcements.Should().HaveCount(1); + _announcementViewModel.Announcements[0].Message.Should().Be(""); + } + + [Test] + public void AcknowledgeAnnouncement_ShouldAddToAcknowledgedList() + { + // Arrange + var announcements = new List + { + new() + { + Id = "1", + Message = new Dictionary { { "en", "Test message" } } + } + }; + + // Setup ObservableCache to emit the announcements + var mockObservableCache = new Mock>(); + var changeSet = new ChangeSet(); + changeSet.Add(new Change(ChangeReason.Add, "1", announcements[0])); + + mockObservableCache + .Setup(x => x.Connect(It.IsAny>(), It.IsAny())) + .Returns(Observable.Return(changeSet)); + + _mockAnnouncementRepository.Setup(x => x.ObservableCache) + .Returns(mockObservableCache.Object); + + // Setup Elements to return the announcements + _mockAnnouncementRepository.Setup(x => x.Elements) + .Returns(announcements); + + _announcementViewModel.Activator.Activate(); + + // Act + _announcementViewModel.AcknowledgeAnnouncementCommand.Execute("1").Subscribe(); + + // Assert + _mockApplicationSettingsRepository.Verify( + x => x.UpdateCurrentApplicationSettings(It.IsAny>(), true), + Times.Once); + } + + [Test] + public void AcknowledgeAnnouncement_ShouldRefreshCollection() + { + // Arrange + var announcements = new List + { + new() + { + Id = "1", + Message = new Dictionary { { "en", "Test message" } } + } + }; + + // Setup ObservableCache to emit the announcements + var mockObservableCache = new Mock>(); + var changeSet = new ChangeSet(); + changeSet.Add(new Change(ChangeReason.Add, "1", announcements[0])); + + mockObservableCache + .Setup(x => x.Connect(It.IsAny>(), It.IsAny())) + .Returns(Observable.Return(changeSet)); + + _mockAnnouncementRepository.Setup(x => x.ObservableCache) + .Returns(mockObservableCache.Object); + + // Setup Elements to return the announcements + _mockAnnouncementRepository.Setup(x => x.Elements) + .Returns(announcements); + + _announcementViewModel.Activator.Activate(); + _announcementViewModel.Announcements.Should().HaveCount(1); + + // Act + _announcementViewModel.AcknowledgeAnnouncementCommand.Execute("1").Subscribe(); + + // Assert + // After acknowledgment, the announcement should be removed from the collection + // This is verified by the fact that Refresh() is called again + _mockApplicationSettingsRepository.Verify( + x => x.GetCurrentApplicationSettings(), + Times.AtLeast(2)); // Called once during initial setup and again during acknowledgment + } + + [Test] + public void WhenActivated_ShouldSubscribeToRepositoryChanges() + { + // Arrange + var mockObservableCache = new Mock>(); + mockObservableCache + .Setup(x => x.Connect(It.IsAny>(), It.IsAny())) + .Returns(Observable.Empty>()); + + _mockAnnouncementRepository.Setup(x => x.ObservableCache) + .Returns(mockObservableCache.Object); + + // Act & Assert + _announcementViewModel.Activator.Activate(); + } + + [Test] + public void WhenActivated_ShouldSubscribeToCultureChanges() + { + // Act + _announcementViewModel.Activator.Activate(); + + // Assert + _mockLocalizationService.Verify(x => x.CurrentCultureObservable, Times.Once); + } + + [Test] + public void WhenDeactivated_ShouldDisposeSubscriptions() + { + // Arrange + _announcementViewModel.Activator.Activate(); + + // Act + _announcementViewModel.Activator.Deactivate(); + + // Assert + // The subscriptions should be disposed when the view model is deactivated + // This is handled by ReactiveUI's WhenActivated mechanism + // If we reach here without exceptions, the test passes + _announcementViewModel.Should().NotBeNull(); + } + + [Test] + public void Refresh_WithRepositoryCacheChanges_ShouldUpdateCollection() + { + // Arrange + var announcements = new List + { + new() + { + Id = "1", + Message = new Dictionary { { "en", "Test message" } } + } + }; + + // Setup ObservableCache to emit the initial announcements + var mockObservableCache = new Mock>(); + var initialChangeSet = new ChangeSet(); + initialChangeSet.Add(new Change(ChangeReason.Add, "1", announcements[0])); + + mockObservableCache + .Setup(x => x.Connect(It.IsAny>(), It.IsAny())) + .Returns(Observable.Return(initialChangeSet)); + + _mockAnnouncementRepository.Setup(x => x.ObservableCache) + .Returns(mockObservableCache.Object); + + // Setup Elements to return the initial announcements + _mockAnnouncementRepository.Setup(x => x.Elements) + .Returns(announcements); + + // Ensure announcements are not acknowledged + _applicationSettings.DecodedAcknowledgedAnnouncementIds = []; + + _announcementViewModel.Activator.Activate(); + _announcementViewModel.Announcements.Should().HaveCount(1); + + // Act - Simulate repository cache change + var newAnnouncements = new List + { + new() + { + Id = "2", + Message = new Dictionary { { "en", "New message" } } + } + }; + + // Setup new change set for the updated announcements + var updatedChangeSet = new ChangeSet(); + updatedChangeSet.Add(new Change(ChangeReason.Remove, "1", announcements[0])); + updatedChangeSet.Add(new Change(ChangeReason.Add, "2", newAnnouncements[0])); + + mockObservableCache + .Setup(x => x.Connect(It.IsAny>(), It.IsAny())) + .Returns(Observable.Return(updatedChangeSet)); + + // Update Elements to return the new announcements + _mockAnnouncementRepository.Setup(x => x.Elements) + .Returns(newAnnouncements); + + // Trigger a refresh by simulating a culture change + _cultureSubject.OnNext(new CultureDefinition { Code = "en" }); + + // Assert + _announcementViewModel.Announcements.Should().HaveCount(1); + _announcementViewModel.Announcements[0].Id.Should().Be("2"); + _announcementViewModel.Announcements[0].Message.Should().Be("New message"); + } + + [Test] + public void Refresh_WithCultureChange_ShouldUpdateMessages() + { + // Arrange + var announcements = new List + { + new() + { + Id = "1", + Message = new Dictionary + { + { "en", "English message" }, + { "fr", "Message français" } + } + } + }; + + // Setup ObservableCache to emit the announcements + var mockObservableCache = new Mock>(); + var changeSet = new ChangeSet(); + changeSet.Add(new Change(ChangeReason.Add, "1", announcements[0])); + + mockObservableCache + .Setup(x => x.Connect(It.IsAny>(), It.IsAny())) + .Returns(Observable.Return(changeSet)); + + _mockAnnouncementRepository.Setup(x => x.ObservableCache) + .Returns(mockObservableCache.Object); + + // Setup Elements to return the announcements + _mockAnnouncementRepository.Setup(x => x.Elements) + .Returns(announcements); + + // Ensure the announcement is not acknowledged + _applicationSettings.DecodedAcknowledgedAnnouncementIds = []; + + _announcementViewModel.Activator.Activate(); + _announcementViewModel.Announcements.Should().HaveCount(1); + _announcementViewModel.Announcements[0].Message.Should().Be("English message"); + + // Act - Change culture + _mockLocalizationService.Setup(x => x.CurrentCultureDefinition) + .Returns(new CultureDefinition { Code = "fr" }); + _cultureSubject.OnNext(new CultureDefinition { Code = "fr" }); + + // Assert + _announcementViewModel.Announcements.Should().HaveCount(1); + _announcementViewModel.Announcements[0].Message.Should().Be("Message français"); + } + + [Test] + public void IsAnnouncementAcknowledged_ShouldReturnCorrectStatus() + { + // Arrange + _applicationSettings.DecodedAcknowledgedAnnouncementIds = ["1", "3"]; + + // Act & Assert + _applicationSettings.IsAnnouncementAcknowledged("1").Should().BeTrue(); + _applicationSettings.IsAnnouncementAcknowledged("2").Should().BeFalse(); + _applicationSettings.IsAnnouncementAcknowledged("3").Should().BeTrue(); + } + + [Test] + public void AddAcknowledgedAnnouncementId_ShouldAddToAcknowledgedList() + { + // Arrange + _applicationSettings.DecodedAcknowledgedAnnouncementIds = ["1"]; + + // Act + _applicationSettings.AddAcknowledgedAnnouncementId("2"); + + // Assert + _applicationSettings.DecodedAcknowledgedAnnouncementIds.Should().Contain("1"); + _applicationSettings.DecodedAcknowledgedAnnouncementIds.Should().Contain("2"); + _applicationSettings.DecodedAcknowledgedAnnouncementIds.Should().HaveCount(2); + } + + [Test] + public void AddAcknowledgedAnnouncementId_ShouldNotAddDuplicate() + { + // Arrange + _applicationSettings.DecodedAcknowledgedAnnouncementIds = ["1"]; + + // Act + _applicationSettings.AddAcknowledgedAnnouncementId("1"); + + // Assert + _applicationSettings.DecodedAcknowledgedAnnouncementIds.Should().Contain("1"); + _applicationSettings.DecodedAcknowledgedAnnouncementIds.Should().HaveCount(1); + } + + [Test] + public void InitializeAcknowledgedAnnouncementIds_ShouldClearList() + { + // Arrange + _applicationSettings.DecodedAcknowledgedAnnouncementIds = ["1", "2", "3"]; + + // Act + _applicationSettings.InitializeAcknowledgedAnnouncementIds(); + + // Assert + _applicationSettings.DecodedAcknowledgedAnnouncementIds.Should().BeEmpty(); + } + + [Test] + public void AnnouncementItemViewModel_ShouldHaveCorrectProperties() + { + // Arrange + var announcementItem = new AnnouncementItemViewModel + { + Id = "test-id", + Message = "Test message" + }; + + // Assert + announcementItem.Id.Should().Be("test-id"); + announcementItem.Message.Should().Be("Test message"); + } + + [Test] + public void Constructor_WithNoParameters_ShouldCreateEmptyViewModel() + { + // Act + var emptyViewModel = new AnnouncementViewModel(); + + // Assert + emptyViewModel.Announcements.Should().NotBeNull(); + emptyViewModel.Announcements.Should().BeEmpty(); + emptyViewModel.IsVisible.Should().BeFalse(); + } + + [Test] + public void AcknowledgeAnnouncementCommand_ShouldBeExecutable() + { + // Assert + _announcementViewModel.AcknowledgeAnnouncementCommand.Should().NotBeNull(); + _announcementViewModel.AcknowledgeAnnouncementCommand.CanExecute.Should().NotBeNull(); + } + + [Test] + public void Refresh_WithNullAcknowledgedIds_ShouldHandleGracefully() + { + // Arrange +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + _applicationSettings.DecodedAcknowledgedAnnouncementIds = null; +#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. + var announcements = new List + { + new() + { + Id = "1", + Message = new Dictionary { { "en", "Test message" } } + } + }; + + // Setup ObservableCache to emit the announcements + var mockObservableCache = new Mock>(); + var changeSet = new ChangeSet(); + changeSet.Add(new Change(ChangeReason.Add, "1", announcements[0])); + + mockObservableCache + .Setup(x => x.Connect(It.IsAny>(), It.IsAny())) + .Returns(Observable.Return(changeSet)); + + _mockAnnouncementRepository.Setup(x => x.ObservableCache) + .Returns(mockObservableCache.Object); + + // Setup Elements to return the announcements + _mockAnnouncementRepository.Setup(x => x.Elements) + .Returns(announcements); + + // Act + _announcementViewModel.Activator.Activate(); + + // Assert + _announcementViewModel.IsVisible.Should().BeTrue(); + _announcementViewModel.Announcements.Should().HaveCount(1); + } +} \ No newline at end of file diff --git a/tests/ByteSync.Functions.UnitTests/Bruno/Get Announcements.bru b/tests/ByteSync.Functions.UnitTests/Bruno/Get Announcements.bru new file mode 100644 index 00000000..80d651b4 --- /dev/null +++ b/tests/ByteSync.Functions.UnitTests/Bruno/Get Announcements.bru @@ -0,0 +1,11 @@ +meta { + name: Get Announcements + type: http + seq: 10 +} + +get { + url: {{root-url}}/announcements + body: none + auth: none +} diff --git a/tests/ByteSync.Functions.UnitTests/Timer/RefreshAnnouncementsFunctionTests.cs b/tests/ByteSync.Functions.UnitTests/Timer/RefreshAnnouncementsFunctionTests.cs new file mode 100644 index 00000000..d11bfdec --- /dev/null +++ b/tests/ByteSync.Functions.UnitTests/Timer/RefreshAnnouncementsFunctionTests.cs @@ -0,0 +1,47 @@ +using ByteSync.Functions.Timer; +using ByteSync.Common.Business.Announcements; +using ByteSync.ServerCommon.Interfaces.Loaders; +using ByteSync.ServerCommon.Interfaces.Repositories; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ByteSync.Functions.UnitTests.Timer; + +[TestFixture] +public class RefreshAnnouncementsFunctionTests +{ + private Mock _loader = null!; + private Mock _repository = null!; + private Mock> _logger = null!; + + [SetUp] + public void SetUp() + { + _loader = new Mock(); + _repository = new Mock(); + _logger = new Mock>(); + } + + [Test] + public async Task RunAsync_ShouldFilterExpiredMessages() + { + // Arrange + var now = DateTime.UtcNow; + var announcements = new List + { + new Announcement { Id = Guid.NewGuid().ToString("D"), StartDate = now.AddHours(-1), EndDate = now.AddHours(1), Message = new Dictionary{{"en","valid"}} }, + new Announcement { Id = Guid.NewGuid().ToString("D"), StartDate = now.AddHours(-2), EndDate = now.AddHours(-1), Message = new Dictionary{{"en","expired"}} } + }; + _loader.Setup(l => l.Load()).ReturnsAsync(announcements); + var function = new RefreshAnnouncementsFunction(_loader.Object, _repository.Object, _logger.Object); + + // Act + var result = await function.RunAsync(new TimerInfo()); + + // Assert + _loader.Verify(l => l.Load(), Times.Once); + _repository.Verify(r => r.SaveAll(It.Is>(l => l.Count == 1 && l[0].Message["en"] == "valid")), Times.Once); + Assert.That(result, Is.EqualTo(1)); + } +} diff --git a/tests/ByteSync.ServerCommon.Tests/Commands/Announcements/GetActiveAnnouncementsCommandHandlerTests.cs b/tests/ByteSync.ServerCommon.Tests/Commands/Announcements/GetActiveAnnouncementsCommandHandlerTests.cs new file mode 100644 index 00000000..7a997fcc --- /dev/null +++ b/tests/ByteSync.ServerCommon.Tests/Commands/Announcements/GetActiveAnnouncementsCommandHandlerTests.cs @@ -0,0 +1,43 @@ +using ByteSync.Common.Business.Announcements; +using ByteSync.ServerCommon.Commands.Announcements; +using ByteSync.ServerCommon.Interfaces.Repositories; +using FakeItEasy; +using FluentAssertions; + +namespace ByteSync.ServerCommon.Tests.Commands.Announcements; + +[TestFixture] +public class GetActiveAnnouncementsCommandHandlerTests +{ + private IAnnouncementRepository _repository = null!; + private GetActiveAnnouncementsCommandHandler _handler = null!; + + [SetUp] + public void Setup() + { + _repository = A.Fake(); + _handler = new GetActiveAnnouncementsCommandHandler(_repository); + } + + [Test] + public async Task Handle_ReturnsOnlyActiveAnnouncements() + { + // Arrange + var now = DateTime.UtcNow; + var announcements = new List + { + new() { Id = "1", StartDate = now.AddHours(-1), EndDate = now.AddHours(1) }, + new() { Id = "2", StartDate = now.AddHours(-2), EndDate = now.AddHours(-1) }, + new() { Id = "3", StartDate = now.AddHours(1), EndDate = now.AddHours(2) } + }; + A.CallTo(() => _repository.GetAll()).Returns(announcements); + + // Act + var result = await _handler.Handle(new GetActiveAnnouncementsRequest(), CancellationToken.None); + + // Assert + result.Should().HaveCount(1); + result[0].Id.Should().Be("1"); + A.CallTo(() => _repository.GetAll()).MustHaveHappenedOnceExactly(); + } +} diff --git a/tests/ByteSync.ServerCommon.Tests/Factories/CacheKeyFactoryTests.cs b/tests/ByteSync.ServerCommon.Tests/Factories/CacheKeyFactoryTests.cs index 8525e817..fb9f9514 100644 --- a/tests/ByteSync.ServerCommon.Tests/Factories/CacheKeyFactoryTests.cs +++ b/tests/ByteSync.ServerCommon.Tests/Factories/CacheKeyFactoryTests.cs @@ -33,6 +33,7 @@ public void Setup() [TestCase(EntityType.ClientSoftwareVersionSettings, "version123", "ClientSoftwareVersionSettings")] [TestCase(EntityType.CloudSessionProfile, "profile123", "CloudSessionProfile")] [TestCase(EntityType.Lobby, "lobby123", "Lobby")] + [TestCase(EntityType.Announcement, "msg123", "Announcement")] public void Create_ShouldGenerateCacheKey_WithCorrectFormat(EntityType entityType, string entityId, string expectedEntityTypeName) { // Arrange diff --git a/tests/ByteSync.ServerCommon.Tests/Helpers/MockHttpMessageHandler.cs b/tests/ByteSync.ServerCommon.Tests/Helpers/MockHttpMessageHandler.cs new file mode 100644 index 00000000..69e29e13 --- /dev/null +++ b/tests/ByteSync.ServerCommon.Tests/Helpers/MockHttpMessageHandler.cs @@ -0,0 +1,26 @@ +using System.Net; +using System.Text; + +namespace ByteSync.ServerCommon.Tests.Helpers; + +public class MockHttpMessageHandler : HttpMessageHandler +{ + private readonly string _responseContent; + private readonly HttpStatusCode _statusCode; + + public MockHttpMessageHandler(string responseContent, HttpStatusCode statusCode = HttpStatusCode.OK) + { + _responseContent = responseContent; + _statusCode = statusCode; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = new HttpResponseMessage(_statusCode) + { + Content = new StringContent(_responseContent, Encoding.UTF8, "application/json") + }; + + return Task.FromResult(response); + } +} \ No newline at end of file diff --git a/tests/ByteSync.ServerCommon.Tests/Loaders/AnnouncementsLoaderTests.cs b/tests/ByteSync.ServerCommon.Tests/Loaders/AnnouncementsLoaderTests.cs new file mode 100644 index 00000000..5d8b490a --- /dev/null +++ b/tests/ByteSync.ServerCommon.Tests/Loaders/AnnouncementsLoaderTests.cs @@ -0,0 +1,185 @@ +using ByteSync.Common.Controls.Json; +using ByteSync.Common.Business.Announcements; +using ByteSync.ServerCommon.Business.Settings; +using ByteSync.ServerCommon.Loaders; +using FakeItEasy; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Net; +using System.Text; +using ByteSync.ServerCommon.Tests.Helpers; + +namespace ByteSync.ServerCommon.Tests.Loaders; + +[TestFixture] +public class AnnouncementsLoaderTests +{ + private ILogger _mockLogger; + private IOptions _mockAppSettings; + private HttpClient _httpClient; + private AnnouncementsLoader _loader; + + [SetUp] + public void SetUp() + { + _mockLogger = A.Fake>(); + _mockAppSettings = Options.Create(new AppSettings + { + AnnouncementsUrl = "https://test.example.com/announcements.json" + }); + _httpClient = new HttpClient(new MockHttpMessageHandler("")); + _loader = new AnnouncementsLoader(_mockAppSettings, _mockLogger, _httpClient); + } + + [TearDown] + public void TearDown() + { + _httpClient?.Dispose(); + } + + [Test] + public async Task Load_ShouldReturnAnnouncements_WhenValidDataIsRetrieved() + { + // Arrange + var announcements = new List + { + new Announcement + { + Id = "msg1", + StartDate = DateTime.UtcNow.AddDays(-1), + EndDate = DateTime.UtcNow.AddDays(1), + Message = new Dictionary + { + { "en", "Test message in English" }, + { "fr", "Message de test en français" } + } + }, + new Announcement + { + Id = "msg2", + StartDate = DateTime.UtcNow, + EndDate = DateTime.UtcNow.AddDays(7), + Message = new Dictionary + { + { "en", "Another test message" }, + { "fr", "Un autre message de test" } + } + } + }; + + var jsonContent = JsonHelper.Serialize(announcements); + _httpClient = new HttpClient(new MockHttpMessageHandler(jsonContent)); + _loader = new AnnouncementsLoader(_mockAppSettings, _mockLogger, _httpClient); + + // Act + var result = await _loader.Load(); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(2); + result[0].Id.Should().Be("msg1"); + result[0].Message["en"].Should().Be("Test message in English"); + result[0].Message["fr"].Should().Be("Message de test en français"); + result[1].Id.Should().Be("msg2"); + } + + [Test] + public async Task Load_ShouldThrowException_WhenHttpRequestFails() + { + // Arrange + _httpClient = new HttpClient(new MockHttpMessageHandler("", HttpStatusCode.NotFound)); + _loader = new AnnouncementsLoader(_mockAppSettings, _mockLogger, _httpClient); + + // Act & Assert + await FluentActions.Awaiting(() => _loader.Load()) + .Should().ThrowAsync(); + } + + [Test] + public async Task Load_ShouldHandleEmptyList_AndReturnEmptyList() + { + // Arrange + var announcements = new List(); + var jsonContent = JsonHelper.Serialize(announcements); + _httpClient = new HttpClient(new MockHttpMessageHandler(jsonContent)); + _loader = new AnnouncementsLoader(_mockAppSettings, _mockLogger, _httpClient); + + // Act + var result = await _loader.Load(); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + [Test] + public async Task Load_ShouldThrowException_WhenDeserializationReturnsNull() + { + // Arrange + var malformedJson = "{ invalid json }"; + _httpClient = new HttpClient(new MockHttpMessageHandler(malformedJson)); + _loader = new AnnouncementsLoader(_mockAppSettings, _mockLogger, _httpClient); + + // Act & Assert + await FluentActions.Awaiting(() => _loader.Load()) + .Should().ThrowAsync(); + } + + [Test] + public async Task Load_ShouldHandleAnnouncementsWithEmptyMessages() + { + // Arrange + var announcements = new List + { + new Announcement + { + Id = "msg1", + StartDate = DateTime.UtcNow.AddDays(-1), + EndDate = DateTime.UtcNow.AddDays(1), + Message = new Dictionary() // Empty messages + } + }; + + var jsonContent = JsonHelper.Serialize(announcements); + _httpClient = new HttpClient(new MockHttpMessageHandler(jsonContent)); + _loader = new AnnouncementsLoader(_mockAppSettings, _mockLogger, _httpClient); + + // Act + var result = await _loader.Load(); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(1); + result[0].Id.Should().Be("msg1"); + result[0].Message.Should().BeEmpty(); + } + + [Test] + public async Task Load_ShouldHandleAnnouncementsWithNullMessages() + { + // Arrange + var announcements = new List + { + new Announcement + { + Id = "msg1", + StartDate = DateTime.UtcNow.AddDays(-1), + EndDate = DateTime.UtcNow.AddDays(1), + Message = null! // Null messages + } + }; + + var jsonContent = JsonHelper.Serialize(announcements); + _httpClient = new HttpClient(new MockHttpMessageHandler(jsonContent)); + _loader = new AnnouncementsLoader(_mockAppSettings, _mockLogger, _httpClient); + + // Act + var result = await _loader.Load(); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(1); + result[0].Id.Should().Be("msg1"); + } +} \ No newline at end of file diff --git a/tests/ByteSync.ServerCommon.Tests/Loaders/ClientSoftwareVersionSettingsLoaderTests.cs b/tests/ByteSync.ServerCommon.Tests/Loaders/ClientSoftwareVersionSettingsLoaderTests.cs new file mode 100644 index 00000000..6adebd33 --- /dev/null +++ b/tests/ByteSync.ServerCommon.Tests/Loaders/ClientSoftwareVersionSettingsLoaderTests.cs @@ -0,0 +1,159 @@ +using ByteSync.Common.Business.Versions; +using ByteSync.Common.Controls.Json; +using ByteSync.ServerCommon.Business.Settings; +using ByteSync.ServerCommon.Loaders; +using FakeItEasy; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Net; +using ByteSync.ServerCommon.Tests.Helpers; + +namespace ByteSync.ServerCommon.Tests.Loaders; + +[TestFixture] +public class ClientSoftwareVersionSettingsLoaderTests +{ + private ILogger _mockLogger; + private IOptions _mockAppSettings; + private HttpClient _httpClient; + private ClientSoftwareVersionSettingsLoader _loader; + + [SetUp] + public void SetUp() + { + _mockLogger = A.Fake>(); + _mockAppSettings = Options.Create(new AppSettings + { + UpdatesDefinitionUrl = "https://test.example.com/updates.json" + }); + _httpClient = new HttpClient(new MockHttpMessageHandler("")); + _loader = new ClientSoftwareVersionSettingsLoader(_mockAppSettings, _mockLogger, _httpClient); + } + + [TearDown] + public void TearDown() + { + _httpClient?.Dispose(); + } + + [Test] + public async Task Load_ShouldReturnClientSoftwareVersionSettings_WhenValidDataIsRetrieved() + { + // Arrange + var softwareVersions = new List + { + new SoftwareVersion + { + ProductCode = "ByteSync", + Version = "1.0.0", + Level = PriorityLevel.Minimal + }, + new SoftwareVersion + { + ProductCode = "ByteSync", + Version = "1.1.0", + Level = PriorityLevel.Recommended + } + }; + + var jsonContent = JsonHelper.Serialize(softwareVersions); + _httpClient = new HttpClient(new MockHttpMessageHandler(jsonContent)); + _loader = new ClientSoftwareVersionSettingsLoader(_mockAppSettings, _mockLogger, _httpClient); + + // Act + var result = await _loader.Load(); + + // Assert + result.Should().NotBeNull(); + result.MinimalVersion.Should().NotBeNull(); + result.MinimalVersion!.Version.Should().Be("1.0.0"); + result.MinimalVersion.Level.Should().Be(PriorityLevel.Minimal); + result.MinimalVersion.ProductCode.Should().Be("ByteSync"); + } + + [Test] + public async Task Load_ShouldThrowException_WhenNoMinimalVersionFound() + { + // Arrange + var softwareVersions = new List + { + new SoftwareVersion + { + ProductCode = "ByteSync", + Version = "1.1.0", + Level = PriorityLevel.Recommended + }, + new SoftwareVersion + { + ProductCode = "ByteSync", + Version = "1.2.0", + Level = PriorityLevel.Optional + } + }; + + var jsonContent = JsonHelper.Serialize(softwareVersions); + _httpClient = new HttpClient(new MockHttpMessageHandler(jsonContent)); + _loader = new ClientSoftwareVersionSettingsLoader(_mockAppSettings, _mockLogger, _httpClient); + + // Act & Assert + var exception = await FluentActions.Awaiting(() => _loader.Load()) + .Should().ThrowAsync(); + exception.WithMessage("Failed to load minimal version"); + } + + [Test] + public async Task Load_ShouldThrowException_WhenHttpRequestFails() + { + // Arrange + _httpClient = new HttpClient(new MockHttpMessageHandler("", HttpStatusCode.NotFound)); + _loader = new ClientSoftwareVersionSettingsLoader(_mockAppSettings, _mockLogger, _httpClient); + + // Act & Assert + await FluentActions.Awaiting(() => _loader.Load()) + .Should().ThrowAsync(); + } + + [Test] + public async Task Load_ShouldHandleEmptyList_AndThrowException() + { + // Arrange + var softwareVersions = new List(); + var jsonContent = JsonHelper.Serialize(softwareVersions); + _httpClient = new HttpClient(new MockHttpMessageHandler(jsonContent)); + _loader = new ClientSoftwareVersionSettingsLoader(_mockAppSettings, _mockLogger, _httpClient); + + // Act & Assert + var exception = await FluentActions.Awaiting(() => _loader.Load()) + .Should().ThrowAsync(); + exception.WithMessage("Failed to load minimal version"); + } + + [Test] + public async Task Load_ShouldHandleNullList_AndThrowException() + { + // Arrange + List? softwareVersions = null; + var jsonContent = JsonHelper.Serialize(softwareVersions); + _httpClient = new HttpClient(new MockHttpMessageHandler(jsonContent)); + _loader = new ClientSoftwareVersionSettingsLoader(_mockAppSettings, _mockLogger, _httpClient); + + // Act & Assert + var exception = await FluentActions.Awaiting(() => _loader.Load()) + .Should().ThrowAsync(); + exception.WithMessage("Failed to deserialize JSON."); + } + + [Test] + public async Task Load_ShouldHandleMalformedJson_AndThrowException() + { + // Arrange + var malformedJson = "{ invalid json }"; + _httpClient = new HttpClient(new MockHttpMessageHandler(malformedJson)); + _loader = new ClientSoftwareVersionSettingsLoader(_mockAppSettings, _mockLogger, _httpClient); + + // Act & Assert + await FluentActions.Awaiting(() => _loader.Load()) + .Should().ThrowAsync(); + } +} \ No newline at end of file