diff --git a/Daybreak.Tests/Services/Credentials/CredentialManagerTests.cs b/Daybreak.Tests/Services/Credentials/CredentialManagerTests.cs new file mode 100644 index 00000000..dcc41020 --- /dev/null +++ b/Daybreak.Tests/Services/Credentials/CredentialManagerTests.cs @@ -0,0 +1,131 @@ +using Daybreak.Configuration.Options; +using Daybreak.Services.Credentials; +using Daybreak.Shared.Models; +using Daybreak.Shared.Services.Credentials; +using Daybreak.Shared.Services.Options; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Daybreak.Tests.Services.Credentials; + +[TestClass] +public sealed class CredentialManagerTests +{ + private readonly ICredentialProtector protector = Substitute.For(); + private readonly IOptionsProvider optionsProvider = Substitute.For(); + private readonly IOptionsMonitor liveOptions = Substitute.For>(); + private readonly CredentialManagerOptions options = new(); + private readonly CredentialManager manager; + + public CredentialManagerTests() + { + this.liveOptions.CurrentValue.Returns(this.options); + // Reversible "encryption" — easy to assert and reverse for round-trip tests. + this.protector.Protect(Arg.Any()).Returns(call => Reverse(call.Arg())); + this.protector.Unprotect(Arg.Any()).Returns(call => Reverse(call.Arg())); + this.manager = new CredentialManager( + this.protector, + this.optionsProvider, + this.liveOptions, + Substitute.For>()); + } + + private static byte[] Reverse(byte[] bytes) + { + var copy = (byte[])bytes.Clone(); + Array.Reverse(copy); + return copy; + } + + [TestMethod] + public void GetCredentialList_EmptyOptions_ReturnsEmpty() + { + this.manager.GetCredentialList().Should().BeEmpty(); + } + + [TestMethod] + public void GetCredentialList_NullCollection_ReturnsEmpty() + { + this.options.ProtectedLoginCredentials = null!; + + this.manager.GetCredentialList().Should().BeEmpty(); + } + + [TestMethod] + public void StoreCredentials_RoundTripsThroughProtector() + { + var creds = new LoginCredentials { Identifier = "id-1", Username = "alice", Password = "s3cret" }; + + this.manager.StoreCredentials([creds]); + var roundTripped = this.manager.GetCredentialList(); + + roundTripped.Should().ContainSingle().Which.Should().BeEquivalentTo(creds); + this.optionsProvider.Received(1).SaveOption(this.options); + this.options.ProtectedLoginCredentials.Should().ContainSingle() + .Which.Identifier.Should().Be("id-1"); + } + + [TestMethod] + public void StoreCredentials_ProtectsUsernameAndPassword_AsBase64ReversedBytes() + { + var creds = new LoginCredentials { Identifier = "id-1", Username = "alice", Password = "pw" }; + + this.manager.StoreCredentials([creds]); + + var stored = this.options.ProtectedLoginCredentials.Single(); + var expectedUsername = Convert.ToBase64String(Reverse(System.Text.Encoding.UTF8.GetBytes("alice"))); + var expectedPassword = Convert.ToBase64String(Reverse(System.Text.Encoding.UTF8.GetBytes("pw"))); + stored.ProtectedUsername.Should().Be(expectedUsername); + stored.ProtectedPassword.Should().Be(expectedPassword); + } + + [TestMethod] + public void GetCredentialList_UnprotectThrows_DropsThatEntry() + { + // Cause the underlying conversion to fail by setting an invalid base64 string for one entry. + this.options.ProtectedLoginCredentials = + [ + new ProtectedLoginCredentials { Identifier = "bad", ProtectedUsername = "!!not-base64!!", ProtectedPassword = "!!not-base64!!" }, + new ProtectedLoginCredentials { Identifier = "good", ProtectedUsername = Convert.ToBase64String(Reverse(System.Text.Encoding.UTF8.GetBytes("user"))), ProtectedPassword = Convert.ToBase64String(Reverse(System.Text.Encoding.UTF8.GetBytes("pass"))) } + ]; + + var result = this.manager.GetCredentialList(); + + result.Should().ContainSingle().Which.Identifier.Should().Be("good"); + } + + [TestMethod] + public void TryGetCredentialsByIdentifier_Found_ReturnsTrueAndCredentials() + { + this.manager.StoreCredentials([new LoginCredentials { Identifier = "id-1", Username = "a", Password = "b" }]); + + var ok = this.manager.TryGetCredentialsByIdentifier("id-1", out var found); + + ok.Should().BeTrue(); + found!.Identifier.Should().Be("id-1"); + } + + [TestMethod] + public void TryGetCredentialsByIdentifier_Missing_ReturnsFalseAndNull() + { + var ok = this.manager.TryGetCredentialsByIdentifier("missing", out var found); + + ok.Should().BeFalse(); + found.Should().BeNull(); + } + + [TestMethod] + public void CreateUniqueCredentials_HasUniqueGuidIdentifierAndEmptyStrings() + { + var a = this.manager.CreateUniqueCredentials(); + var b = this.manager.CreateUniqueCredentials(); + + a.Identifier.Should().NotBeNullOrEmpty(); + Guid.TryParse(a.Identifier, out _).Should().BeTrue(); + a.Identifier.Should().NotBe(b.Identifier); + a.Username.Should().BeEmpty(); + a.Password.Should().BeEmpty(); + } +} diff --git a/Daybreak.Tests/Services/Events/EventServiceTests.cs b/Daybreak.Tests/Services/Events/EventServiceTests.cs new file mode 100644 index 00000000..b8318661 --- /dev/null +++ b/Daybreak.Tests/Services/Events/EventServiceTests.cs @@ -0,0 +1,114 @@ +using Daybreak.Services.Events; +using Daybreak.Shared.Models.Guildwars; +using FluentAssertions; + +namespace Daybreak.Tests.Services.Events; + +[TestClass] +public sealed class EventServiceTests +{ + private readonly EventService service = new(); + + [TestMethod] + public void GetActiveEvents_DuringHalloween_ReturnsHalloween() + { + // Halloween: 10/18 19:00 UTC → 11/2 19:00 UTC + var midHalloween = new DateTime(2024, 10, 25, 12, 0, 0, DateTimeKind.Utc); + + var active = this.service.GetActiveEvents(midHalloween); + + active.Should().Contain(e => e.Title == "Halloween"); + } + + [TestMethod] + public void GetActiveEvents_BeforeEventStart_DoesNotIncludeEvent() + { + // Halloween starts at 10/18 19:00 UTC; 10/18 18:59 UTC is still before. + var justBefore = new DateTime(2024, 10, 18, 18, 59, 0, DateTimeKind.Utc); + + var active = this.service.GetActiveEvents(justBefore); + + active.Should().NotContain(e => e.Title == "Halloween"); + } + + [TestMethod] + public void GetActiveEvents_AtEventStartTime_IncludesEvent() + { + // Exactly at 10/18 19:00 UTC the event becomes active. + var atStart = new DateTime(2024, 10, 18, 19, 0, 0, DateTimeKind.Utc); + + var active = this.service.GetActiveEvents(atStart); + + active.Should().Contain(e => e.Title == "Halloween"); + } + + [TestMethod] + public void GetActiveEvents_AfterEventEnd_DoesNotIncludeEvent() + { + // Halloween ends 11/2 19:00 UTC; one minute later it's gone. + var afterEnd = new DateTime(2024, 11, 2, 19, 1, 0, DateTimeKind.Utc); + + var active = this.service.GetActiveEvents(afterEnd); + + active.Should().NotContain(e => e.Title == "Halloween"); + } + + [TestMethod] + public void GetActiveEvents_OverlappingWindow_ReturnsAllOverlapping() + { + // 11/1 — Breast Cancer Awareness Month (10/1-11/2) AND Halloween (10/18-11/2) overlap. + var overlap = new DateTime(2024, 11, 1, 12, 0, 0, DateTimeKind.Utc); + + var active = this.service.GetActiveEvents(overlap); + + active.Select(e => e.Title).Should().Contain(["Halloween", "Breast Cancer Awareness Month"]); + } + + [TestMethod] + public void GetActiveEvents_QuietPeriod_ReturnsEmpty() + { + // Mid-February — between Canthan New Year (ends 2/7) and Lucky Treats Week (3/14). + var quiet = new DateTime(2024, 2, 20, 12, 0, 0, DateTimeKind.Utc); + + var active = this.service.GetActiveEvents(quiet); + + active.Should().BeEmpty(); + } + + [TestMethod] + public void GetEventStartTime_PastEventThisYear_RollsForwardOneYear() + { + // If today is past the event's From date this calendar year, GetEventStartTime + // must reflect *next* year's occurrence. + var halloween = Event.Halloween; + + var startTime = this.service.GetEventStartTime(halloween); + + startTime.Should().BeAfter(DateTime.UtcNow); + startTime.Month.Should().Be(halloween.From.Month); + startTime.Day.Should().Be(halloween.From.Day); + startTime.Hour.Should().Be(19); + startTime.Kind.Should().Be(DateTimeKind.Utc); + } + + [TestMethod] + public void GetUpcomingEvent_AlwaysReturnsAnEvent() + { + var upcoming = this.service.GetUpcomingEvent(); + + upcoming.Should().NotBeNull(); + Event.Events.Should().Contain(upcoming); + } + + [TestMethod] + public void GetLocalizedEventStartTime_ConvertsFrom19UtcToLocal() + { + var today = DateTime.UtcNow.Date; + var expected = TimeOnly.FromDateTime( + new DateTime(today.Year, today.Month, today.Day, 19, 0, 0, DateTimeKind.Utc).ToLocalTime()); + + var localized = this.service.GetLocalizedEventStartTime(); + + localized.Should().Be(expected); + } +} diff --git a/Daybreak.Tests/Services/ExceptionHandling/ExceptionHandlerTests.cs b/Daybreak.Tests/Services/ExceptionHandling/ExceptionHandlerTests.cs new file mode 100644 index 00000000..b3a1a715 --- /dev/null +++ b/Daybreak.Tests/Services/ExceptionHandling/ExceptionHandlerTests.cs @@ -0,0 +1,104 @@ +using Daybreak.Services.ExceptionHandling; +using Daybreak.Services.Notifications.Handlers; +using Daybreak.Shared.Exceptions; +using Daybreak.Shared.Services.ExceptionHandling; +using Daybreak.Shared.Services.Notifications; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Daybreak.Tests.Services.ExceptionHandling; + +[TestClass] +public sealed class ExceptionHandlerTests +{ + private readonly ICrashDumpService crashDumpService = Substitute.For(); + private readonly INotificationService notificationService = Substitute.For(); + private readonly ExceptionHandler handler; + + public ExceptionHandlerTests() + { + this.handler = new ExceptionHandler( + this.crashDumpService, + this.notificationService, + Substitute.For>()); + } + + [TestMethod] + public void HandleException_TaskCanceledException_IsHandledAndNoNotification() + { + var handled = this.handler.HandleException(new TaskCanceledException()); + + handled.Should().BeTrue(); + this.notificationService.DidNotReceiveWithAnyArgs().NotifyError(default!, default!); + } + + [TestMethod] + public void HandleException_OperationCanceledException_IsHandled() + { + var handled = this.handler.HandleException(new OperationCanceledException()); + + handled.Should().BeTrue(); + this.notificationService.DidNotReceiveWithAnyArgs().NotifyError(default!, default!); + } + + [TestMethod] + public void HandleException_AggregateOfOperationCancelled_IsHandled() + { + var aggregate = new AggregateException(new OperationCanceledException()); + + var handled = this.handler.HandleException(aggregate); + + handled.Should().BeTrue(); + this.notificationService.DidNotReceiveWithAnyArgs().NotifyError(default!, default!); + } + + [TestMethod] + public void HandleException_GuildwarsProcessIdArgumentException_IsHandled() + { + var ex = new ArgumentException("Process with an Id of 12345 was not found."); + + var handled = this.handler.HandleException(ex); + + handled.Should().BeTrue(); + this.notificationService.DidNotReceiveWithAnyArgs().NotifyError(default!, default!); + } + + [TestMethod] + public void HandleException_UnhandledException_NotifiesAndReturnsTrue() + { + var ex = new InvalidOperationException("boom"); + + var handled = this.handler.HandleException(ex); + + handled.Should().BeTrue(); + this.notificationService.Received(1).NotifyError( + nameof(InvalidOperationException), + Arg.Is(s => s.Contains("boom")), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + this.crashDumpService.DidNotReceiveWithAnyArgs().WriteCrashDump(default!); + } + + [TestMethod] + public void HandleException_FatalException_WritesCrashDumpAndReturnsFalse() + { + // Allow crash file path to be writable in test output directory. + try + { + var ex = new FatalException("fatal"); + + var handled = this.handler.HandleException(ex); + + handled.Should().BeFalse(); + this.crashDumpService.ReceivedWithAnyArgs(1).WriteCrashDump(default!); + } + finally + { + var crashes = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Crashes"); + if (Directory.Exists(crashes)) + { + Directory.Delete(crashes, recursive: true); + } + } + } +} diff --git a/Daybreak.Tests/Services/LaunchConfigurations/LaunchConfigurationServiceTests.cs b/Daybreak.Tests/Services/LaunchConfigurations/LaunchConfigurationServiceTests.cs new file mode 100644 index 00000000..cdc347cf --- /dev/null +++ b/Daybreak.Tests/Services/LaunchConfigurations/LaunchConfigurationServiceTests.cs @@ -0,0 +1,230 @@ +using Daybreak.Configuration.Options; +using Daybreak.Services.LaunchConfigurations; +using Daybreak.Shared.Models; +using Daybreak.Shared.Models.LaunchConfigurations; +using Daybreak.Shared.Services.Credentials; +using Daybreak.Shared.Services.ExecutableManagement; +using Daybreak.Shared.Services.Options; +using FluentAssertions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Daybreak.Tests.Services.LaunchConfigurations; + +[TestClass] +public sealed class LaunchConfigurationServiceTests +{ + private readonly IOptionsMonitor liveOptions = Substitute.For>(); + private readonly IOptionsProvider optionsProvider = Substitute.For(); + private readonly ICredentialManager credentialManager = Substitute.For(); + private readonly IGuildWarsExecutableManager executableManager = Substitute.For(); + private readonly LaunchConfigurationServiceOptions options = new(); + private readonly LaunchConfigurationService service; + + public LaunchConfigurationServiceTests() + { + this.liveOptions.CurrentValue.Returns(this.options); + this.service = new LaunchConfigurationService( + this.liveOptions, this.optionsProvider, this.credentialManager, this.executableManager); + } + + private void SeedCredentials(params LoginCredentials[] creds) + { + foreach (var c in creds) + { + var local = c; + this.credentialManager.TryGetCredentialsByIdentifier(c.Identifier!, out Arg.Any()) + .Returns(call => { call[1] = local; return true; }); + } + } + + [TestMethod] + public void CreateConfiguration_ReturnsConfigWithFreshGuidIdentifier() + { + var cfg = this.service.CreateConfiguration(); + + cfg.Should().NotBeNull(); + Guid.TryParse(cfg!.Identifier, out _).Should().BeTrue(); + } + + [TestMethod] + public void GetLaunchConfigurations_DropsConfigsWithMissingCredentials() + { + this.options.LaunchConfigurations = + [ + new LaunchConfiguration { Identifier = "cfg-1", CredentialsIdentifier = "missing" }, + ]; + + this.service.GetLaunchConfigurations().Should().BeEmpty(); + } + + [TestMethod] + public void GetLaunchConfigurations_DropsConfigsWithInvalidExecutable() + { + var cred = new LoginCredentials { Identifier = "cred-1" }; + this.SeedCredentials(cred); + this.executableManager.IsValidExecutable("bad.exe").Returns(false); + this.options.LaunchConfigurations = + [ + new LaunchConfiguration { Identifier = "cfg-1", CredentialsIdentifier = "cred-1", Executable = "bad.exe" }, + ]; + + this.service.GetLaunchConfigurations().Should().BeEmpty(); + } + + [TestMethod] + public void GetLaunchConfigurations_ValidConfig_ReturnsItWithCredentials() + { + var cred = new LoginCredentials { Identifier = "cred-1", Username = "u" }; + this.SeedCredentials(cred); + this.executableManager.IsValidExecutable("good.exe").Returns(true); + this.options.LaunchConfigurations = + [ + new LaunchConfiguration { Identifier = "cfg-1", CredentialsIdentifier = "cred-1", Executable = "good.exe", Name = "Main", SteamSupport = false } + ]; + + var configs = this.service.GetLaunchConfigurations().ToList(); + + configs.Should().ContainSingle(); + configs[0].Identifier.Should().Be("cfg-1"); + configs[0].Credentials.Should().BeSameAs(cred); + configs[0].ExecutablePath.Should().Be("good.exe"); + configs[0].SteamSupport.Should().BeFalse(); + } + + [TestMethod] + public void SaveConfiguration_MissingCredentials_ReturnsFalseAndDoesNotPersist() + { + var config = new LaunchConfigurationWithCredentials + { + Identifier = "cfg-1", + Credentials = new LoginCredentials { Identifier = "missing" }, + }; + + this.service.SaveConfiguration(config).Should().BeFalse(); + this.optionsProvider.DidNotReceiveWithAnyArgs().SaveOption(Arg.Any()); + } + + [TestMethod] + public void SaveConfiguration_InvalidExecutable_ReturnsFalse() + { + var cred = new LoginCredentials { Identifier = "cred-1" }; + this.SeedCredentials(cred); + this.executableManager.IsValidExecutable("bad.exe").Returns(false); + var config = new LaunchConfigurationWithCredentials + { + Identifier = "cfg-1", + Credentials = cred, + ExecutablePath = "bad.exe", + }; + + this.service.SaveConfiguration(config).Should().BeFalse(); + this.optionsProvider.DidNotReceiveWithAnyArgs().SaveOption(Arg.Any()); + } + + [TestMethod] + public void SaveConfiguration_NewConfig_InsertsAtFrontAndPersists() + { + var cred = new LoginCredentials { Identifier = "cred-1" }; + this.SeedCredentials(cred); + this.executableManager.IsValidExecutable("gw.exe").Returns(true); + this.options.LaunchConfigurations = + [ + new LaunchConfiguration { Identifier = "old", CredentialsIdentifier = "cred-1", Executable = "gw.exe" } + ]; + var fresh = new LaunchConfigurationWithCredentials + { + Identifier = "new", + Credentials = cred, + ExecutablePath = "gw.exe", + Name = "New", + }; + + var ok = this.service.SaveConfiguration(fresh); + + ok.Should().BeTrue(); + this.options.LaunchConfigurations.Select(c => c.Identifier) + .Should().BeEquivalentTo(["new", "old"], opt => opt.WithStrictOrdering()); + this.optionsProvider.Received(1).SaveOption(this.options); + } + + [TestMethod] + public void SaveConfiguration_ExistingIdentifier_UpdatesInPlace() + { + var cred = new LoginCredentials { Identifier = "cred-1" }; + this.SeedCredentials(cred); + this.executableManager.IsValidExecutable("gw.exe").Returns(true); + this.options.LaunchConfigurations = + [ + new LaunchConfiguration { Identifier = "cfg-1", CredentialsIdentifier = "cred-1", Executable = "gw.exe", Name = "Old" } + ]; + var updated = new LaunchConfigurationWithCredentials + { + Identifier = "cfg-1", + Credentials = cred, + ExecutablePath = "gw.exe", + Name = "Updated", + }; + + this.service.SaveConfiguration(updated).Should().BeTrue(); + + this.options.LaunchConfigurations.Should().ContainSingle() + .Which.Name.Should().Be("Updated"); + } + + [TestMethod] + public void DeleteConfiguration_Missing_ReturnsFalse() + { + var cfg = new LaunchConfigurationWithCredentials { Identifier = "nope" }; + + this.service.DeleteConfiguration(cfg).Should().BeFalse(); + this.optionsProvider.DidNotReceiveWithAnyArgs().SaveOption(Arg.Any()); + } + + [TestMethod] + public void DeleteConfiguration_Existing_RemovesAndPersists() + { + this.options.LaunchConfigurations = + [ + new LaunchConfiguration { Identifier = "cfg-1" }, + new LaunchConfiguration { Identifier = "cfg-2" }, + ]; + var cfg = new LaunchConfigurationWithCredentials { Identifier = "cfg-1" }; + + this.service.DeleteConfiguration(cfg).Should().BeTrue(); + + this.options.LaunchConfigurations.Should().ContainSingle() + .Which.Identifier.Should().Be("cfg-2"); + this.optionsProvider.Received(1).SaveOption(this.options); + } + + [TestMethod] + public void SaveLaunchConfigurations_FiltersInvalidAndConfigsWithMissingCredentials() + { + var cred = new LoginCredentials { Identifier = "cred-1" }; + this.SeedCredentials(cred); + this.executableManager.IsValidExecutable("gw.exe").Returns(true); + this.executableManager.IsValidExecutable("bad.exe").Returns(false); + + var configs = new List + { + new() { Identifier = "valid", Credentials = cred, ExecutablePath = "gw.exe" }, + new() { Identifier = "invalid-exe", Credentials = cred, ExecutablePath = "bad.exe" }, + new() { Identifier = "no-cred", Credentials = new LoginCredentials { Identifier = "missing" }, ExecutablePath = "gw.exe" }, + }; + + this.service.SaveLaunchConfigurations(configs); + + this.options.LaunchConfigurations.Should().ContainSingle() + .Which.Identifier.Should().Be("valid"); + this.optionsProvider.Received(1).SaveOption(this.options); + } + + [TestMethod] + public void IsValid_NullExecutable_IsValid() + { + var cfg = new LaunchConfigurationWithCredentials { Identifier = "x", ExecutablePath = null }; + + this.service.IsValid(cfg).Should().BeTrue(); + } +} diff --git a/Daybreak.Tests/Services/Menu/MenuServiceTests.cs b/Daybreak.Tests/Services/Menu/MenuServiceTests.cs new file mode 100644 index 00000000..1fcd934d --- /dev/null +++ b/Daybreak.Tests/Services/Menu/MenuServiceTests.cs @@ -0,0 +1,95 @@ +using Daybreak.Services.Menu; +using Daybreak.Shared.Models.Menu; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Daybreak.Tests.Services.Menu; + +[TestClass] +public sealed class MenuServiceTests +{ + private readonly Dictionary categories; + private readonly IServiceProvider serviceProvider = Substitute.For(); + private readonly MenuService service; + + public MenuServiceTests() + { + var settings = new MenuCategoryBuilder("Settings").Build(); + this.categories = new Dictionary { ["Settings"] = settings }; + this.service = new MenuService(this.categories, this.serviceProvider, Substitute.For>()); + } + + [TestMethod] + public void GetCategories_ReturnsAllRegisteredCategories() + { + this.service.GetCategories().Should().ContainSingle(c => c.Name == "Settings"); + } + + [TestMethod] + public void OpenMenu_AfterInitialize_InvokesOpenAction() + { + var openCalled = 0; + this.service.InitializeMenuService(() => openCalled++, () => { }, () => { }); + + this.service.OpenMenu(); + + openCalled.Should().Be(1); + } + + [TestMethod] + public void CloseMenu_AfterInitialize_InvokesCloseAction() + { + var closeCalled = 0; + this.service.InitializeMenuService(() => { }, () => closeCalled++, () => { }); + + this.service.CloseMenu(); + + closeCalled.Should().Be(1); + } + + [TestMethod] + public void ToggleMenu_AfterInitialize_InvokesToggleAction() + { + var toggleCalled = 0; + this.service.InitializeMenuService(() => { }, () => { }, () => toggleCalled++); + + this.service.ToggleMenu(); + + toggleCalled.Should().Be(1); + } + + [TestMethod] + public void OpenMenu_WithoutInitialize_DoesNotThrow() + { + var act = this.service.OpenMenu; + + act.Should().NotThrow(); + } + + [TestMethod] + public void HandleButton_InvokesButtonActionWithServiceProvider() + { + IServiceProvider? receivedSp = null; + var button = new MenuButton("Open", "Opens", sp => receivedSp = sp); + + this.service.HandleButton(button); + + receivedSp.Should().BeSameAs(this.serviceProvider); + } + + private sealed class MenuCategoryBuilder(string name) + { + public MenuCategory Build() + { + // MenuCategory has internal ctor in the Shared assembly; reach for it via reflection + // to avoid exposing it just for tests. + return (MenuCategory)Activator.CreateInstance( + typeof(MenuCategory), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, + binder: null, + args: [name], + culture: null)!; + } + } +} diff --git a/Daybreak.Tests/Services/Notifications/InMemoryNotificationStorageTests.cs b/Daybreak.Tests/Services/Notifications/InMemoryNotificationStorageTests.cs new file mode 100644 index 00000000..a61cd5ca --- /dev/null +++ b/Daybreak.Tests/Services/Notifications/InMemoryNotificationStorageTests.cs @@ -0,0 +1,91 @@ +using Daybreak.Services.Notifications; +using Daybreak.Services.Notifications.Models; +using FluentAssertions; + +namespace Daybreak.Tests.Services.Notifications; + +[TestClass] +public sealed class InMemoryNotificationStorageTests +{ + private readonly InMemoryNotificationStorage storage = new(); + + private static NotificationDTO MakeDTO(string id, bool closed = false) => new() + { + Id = id, + Title = $"T-{id}", + Description = $"D-{id}", + CreationTime = DateTime.UtcNow.ToBinary(), + ExpirationTime = DateTime.UtcNow.AddMinutes(1).ToBinary(), + Closed = closed + }; + + [TestMethod] + public async Task StoreNotification_ThenGetNotifications_ReturnsStoredItems() + { + var n1 = MakeDTO("1"); + var n2 = MakeDTO("2"); + + await this.storage.StoreNotification(n1, default); + await this.storage.StoreNotification(n2, default); + + var all = await this.storage.GetNotifications(default); + + all.Select(n => n.Id).Should().BeEquivalentTo(["1", "2"]); + } + + [TestMethod] + public async Task GetPendingNotifications_FiltersClosedOnes() + { + var open = MakeDTO("open"); + var closed = MakeDTO("closed", closed: true); + await this.storage.StoreNotification(open, default); + await this.storage.StoreNotification(closed, default); + + var pending = await this.storage.GetPendingNotifications(default); + + pending.Select(n => n.Id).Should().ContainSingle().Which.Should().Be("open"); + } + + [TestMethod] + public async Task OpenNotification_MarksClosed() + { + var dto = MakeDTO("1"); + + await this.storage.OpenNotification(dto, default); + + dto.Closed.Should().BeTrue(); + } + + [TestMethod] + public async Task RemoveNotification_RemovesByReference() + { + var dto = MakeDTO("1"); + await this.storage.StoreNotification(dto, default); + + await this.storage.RemoveNotification(dto, default); + + (await this.storage.GetNotifications(default)).Should().BeEmpty(); + } + + [TestMethod] + public async Task RemoveAllNotifications_ClearsStorage() + { + await this.storage.StoreNotification(MakeDTO("1"), default); + await this.storage.StoreNotification(MakeDTO("2"), default); + + await this.storage.RemoveAllNotifications(default); + + (await this.storage.GetNotifications(default)).Should().BeEmpty(); + } + + [TestMethod] + public async Task GetNotifications_ReturnsSnapshot_NotLiveCollection() + { + await this.storage.StoreNotification(MakeDTO("1"), default); + var snapshot = await this.storage.GetNotifications(default); + + await this.storage.StoreNotification(MakeDTO("2"), default); + + snapshot.Should().HaveCount(1); + } +} diff --git a/Daybreak.Tests/Services/Notifications/NotificationServiceTests.cs b/Daybreak.Tests/Services/Notifications/NotificationServiceTests.cs new file mode 100644 index 00000000..9b9877ac --- /dev/null +++ b/Daybreak.Tests/Services/Notifications/NotificationServiceTests.cs @@ -0,0 +1,126 @@ +using Daybreak.Services.Notifications; +using Daybreak.Services.Notifications.Handlers; +using Daybreak.Services.Notifications.Models; +using Daybreak.Shared.Models.Notifications; +using Daybreak.Shared.Models.Notifications.Handling; +using Daybreak.Shared.Services.Notifications; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Daybreak.Tests.Services.Notifications; + +[TestClass] +public sealed class NotificationServiceTests +{ + private readonly INotificationStorage storage = Substitute.For(); + private readonly IServiceProvider serviceProvider = Substitute.For(); + private readonly TaskCompletionSource storedDto = new(); + private readonly NotificationService service; + + public NotificationServiceTests() + { + this.storage.When(s => s.StoreNotification(Arg.Any(), Arg.Any())) + .Do(call => this.storedDto.TrySetResult(call.Arg())); + + this.service = new NotificationService( + this.serviceProvider, + this.storage, + Substitute.For>()); + } + + [TestMethod] + public async Task NotifyInformation_Persistent_StoresNotificationWithInformationLevel() + { + var token = this.service.NotifyInformation("title", "desc", metaData: "meta"); + + var stored = await this.storedDto.Task.WaitAsync(TimeSpan.FromSeconds(2)); + stored.Title.Should().Be("title"); + stored.Description.Should().Be("desc"); + stored.MetaData.Should().Be("meta"); + stored.Level.Should().Be((int)LogLevel.Information); + stored.HandlerType.Should().Contain(nameof(NoActionHandler)); + token.Closed.Should().BeFalse(); + } + + [TestMethod] + public async Task NotifyError_WithHandlingType_StoresNotificationWithErrorLevelAndHandler() + { + this.service.NotifyError("err", "boom"); + + var stored = await this.storedDto.Task.WaitAsync(TimeSpan.FromSeconds(2)); + stored.Level.Should().Be((int)LogLevel.Error); + stored.HandlerType.Should().Contain(nameof(TestHandler)); + } + + [TestMethod] + public void NotifyInformation_NotPersistent_DoesNotCallStorage() + { + this.service.NotifyInformation("t", "d", persistent: false); + + this.storage.DidNotReceiveWithAnyArgs().StoreNotification(default!, default); + } + + [TestMethod] + public async Task OpenNotification_WithHandlingType_ResolvesAndInvokesHandler() + { + var handler = new TestHandler(); + this.serviceProvider.GetService(typeof(IEnumerable)) + .Returns(new INotificationHandler[] { handler }); + var notification = (Notification)Activator.CreateInstance( + typeof(Notification), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, + binder: null, args: null, culture: null)!; + typeof(Notification).GetProperty(nameof(Notification.Title))!.SetValue(notification, "t"); + typeof(Notification).GetProperty(nameof(Notification.HandlingType))!.SetValue(notification, typeof(TestHandler)); + + await ((INotificationProducer)this.service).OpenNotification(notification, storeNotification: false); + + handler.OpenedWith.Should().BeSameAs(notification); + } + + [TestMethod] + public async Task GetAllNotifications_DelegatesToStorageAndMapsDtos() + { + var dto = new NotificationDTO + { + Id = "abc", + Title = "t", + Description = "d", + MetaData = "meta", + Level = (int)LogLevel.Warning, + CreationTime = DateTime.UtcNow.ToBinary(), + ExpirationTime = DateTime.UtcNow.AddMinutes(1).ToBinary(), + HandlerType = typeof(NoActionHandler).AssemblyQualifiedName, + Dismissible = true, + Closed = false, + }; + this.storage.GetNotifications(Arg.Any()).Returns(new[] { dto }); + + var result = (await ((INotificationProducer)this.service).GetAllNotifications(default)).ToList(); + + result.Should().ContainSingle(); + var n = result[0]; + n.Id.Should().Be("abc"); + n.Title.Should().Be("t"); + n.Description.Should().Be("d"); + n.Metadata.Should().Be("meta"); + n.Level.Should().Be(LogLevel.Warning); + n.HandlingType.Should().Be(typeof(NoActionHandler)); + n.Dismissible.Should().BeTrue(); + } + + [TestMethod] + public async Task RemoveAllNotifications_DelegatesToStorage() + { + await ((INotificationProducer)this.service).RemoveAllNotifications(default); + + await this.storage.Received(1).RemoveAllNotifications(Arg.Any()); + } + + private sealed class TestHandler : INotificationHandler + { + public Notification? OpenedWith { get; private set; } + public void OpenNotification(Notification notification) => this.OpenedWith = notification; + } +} diff --git a/Daybreak.Tests/Services/Onboarding/OnboardingServiceTests.cs b/Daybreak.Tests/Services/Onboarding/OnboardingServiceTests.cs new file mode 100644 index 00000000..58791b59 --- /dev/null +++ b/Daybreak.Tests/Services/Onboarding/OnboardingServiceTests.cs @@ -0,0 +1,67 @@ +using Daybreak.Services.Onboarding; +using Daybreak.Shared.Models; +using Daybreak.Shared.Models.LaunchConfigurations; +using Daybreak.Shared.Models.Onboarding; +using Daybreak.Shared.Services.Credentials; +using Daybreak.Shared.Services.ExecutableManagement; +using Daybreak.Shared.Services.LaunchConfigurations; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Daybreak.Tests.Services.Onboarding; + +[TestClass] +public sealed class OnboardingServiceTests +{ + private readonly ICredentialManager credentialManager = Substitute.For(); + private readonly IGuildWarsExecutableManager executableManager = Substitute.For(); + private readonly ILaunchConfigurationService launchConfigurationService = Substitute.For(); + private readonly OnboardingService service; + + public OnboardingServiceTests() + { + this.service = new OnboardingService( + this.credentialManager, + this.executableManager, + this.launchConfigurationService, + Substitute.For>()); + } + + [TestMethod] + public void CheckOnboardingStage_NoCredentials_ReturnsNeedsCredentials() + { + this.credentialManager.GetCredentialList().Returns([]); + + this.service.CheckOnboardingStage().Should().Be(LauncherOnboardingStage.NeedsCredentials); + } + + [TestMethod] + public void CheckOnboardingStage_NoExecutable_ReturnsNeedsExecutable() + { + this.credentialManager.GetCredentialList().Returns([new LoginCredentials { Identifier = "x" }]); + this.executableManager.GetExecutableList().Returns([]); + + this.service.CheckOnboardingStage().Should().Be(LauncherOnboardingStage.NeedsExecutable); + } + + [TestMethod] + public void CheckOnboardingStage_NoLaunchConfiguration_ReturnsNeedsConfiguration() + { + this.credentialManager.GetCredentialList().Returns([new LoginCredentials { Identifier = "x" }]); + this.executableManager.GetExecutableList().Returns(["/bin/gw.exe"]); + this.launchConfigurationService.GetLaunchConfigurations().Returns([]); + + this.service.CheckOnboardingStage().Should().Be(LauncherOnboardingStage.NeedsConfiguration); + } + + [TestMethod] + public void CheckOnboardingStage_AllConfigured_ReturnsComplete() + { + this.credentialManager.GetCredentialList().Returns([new LoginCredentials { Identifier = "x" }]); + this.executableManager.GetExecutableList().Returns(["/bin/gw.exe"]); + this.launchConfigurationService.GetLaunchConfigurations().Returns([new LaunchConfigurationWithCredentials { Identifier = "1" }]); + + this.service.CheckOnboardingStage().Should().Be(LauncherOnboardingStage.Complete); + } +} diff --git a/Daybreak.Tests/Services/Startup/StartupActionManagerTests.cs b/Daybreak.Tests/Services/Startup/StartupActionManagerTests.cs new file mode 100644 index 00000000..1bda4095 --- /dev/null +++ b/Daybreak.Tests/Services/Startup/StartupActionManagerTests.cs @@ -0,0 +1,68 @@ +using Daybreak.Services.Startup; +using Daybreak.Shared.Models; +using FluentAssertions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Daybreak.Tests.Services.Startup; + +[TestClass] +public sealed class StartupActionManagerTests +{ + [TestMethod] + public async Task StartAsync_InvokesExecuteOnStartupAndExecuteOnStartupAsync_OnEveryAction() + { + var a = new RecordingStartupAction(); + var b = new RecordingStartupAction(); + var manager = new StartupActionManager([a, b], Substitute.For>()); + + await ((IHostedService)manager).StartAsync(default); + + a.SyncCalled.Should().BeTrue(); + b.SyncCalled.Should().BeTrue(); + a.AsyncCalled.Should().BeTrue(); + b.AsyncCalled.Should().BeTrue(); + } + + [TestMethod] + public async Task StartAsync_AsyncActionThrows_LogsAndCompletesWithoutPropagating() + { + var ok = new RecordingStartupAction(); + var failing = new ThrowingStartupAction(); + var logger = Substitute.For>(); + var manager = new StartupActionManager([ok, failing], logger); + + Func act = () => ((IHostedService)manager).StartAsync(default); + + await act.Should().NotThrowAsync(); + ok.AsyncCalled.Should().BeTrue(); + } + + [TestMethod] + public async Task StopAsync_IsNoOpAndCompletes() + { + var manager = new StartupActionManager([], Substitute.For>()); + + await ((IHostedService)manager).StopAsync(default); + } + + private sealed class RecordingStartupAction : StartupActionBase + { + public bool SyncCalled { get; private set; } + public bool AsyncCalled { get; private set; } + + public override void ExecuteOnStartup() => this.SyncCalled = true; + public override Task ExecuteOnStartupAsync(CancellationToken cancellationToken) + { + this.AsyncCalled = true; + return Task.CompletedTask; + } + } + + private sealed class ThrowingStartupAction : StartupActionBase + { + public override Task ExecuteOnStartupAsync(CancellationToken cancellationToken) => + Task.FromException(new InvalidOperationException("boom")); + } +}