Add more testing coverage (#1543)

This commit is contained in:
2026-05-17 14:28:34 +02:00
parent ca154827b2
commit 7e72cb3d3f
9 changed files with 1026 additions and 0 deletions
@@ -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<ICredentialProtector>();
private readonly IOptionsProvider optionsProvider = Substitute.For<IOptionsProvider>();
private readonly IOptionsMonitor<CredentialManagerOptions> liveOptions = Substitute.For<IOptionsMonitor<CredentialManagerOptions>>();
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<byte[]>()).Returns(call => Reverse(call.Arg<byte[]>()));
this.protector.Unprotect(Arg.Any<byte[]>()).Returns(call => Reverse(call.Arg<byte[]>()));
this.manager = new CredentialManager(
this.protector,
this.optionsProvider,
this.liveOptions,
Substitute.For<ILogger<CredentialManager>>());
}
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();
}
}
@@ -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);
}
}
@@ -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<ICrashDumpService>();
private readonly INotificationService notificationService = Substitute.For<INotificationService>();
private readonly ExceptionHandler handler;
public ExceptionHandlerTests()
{
this.handler = new ExceptionHandler(
this.crashDumpService,
this.notificationService,
Substitute.For<ILogger<ExceptionHandler>>());
}
[TestMethod]
public void HandleException_TaskCanceledException_IsHandledAndNoNotification()
{
var handled = this.handler.HandleException(new TaskCanceledException());
handled.Should().BeTrue();
this.notificationService.DidNotReceiveWithAnyArgs().NotifyError<MessageBoxHandler>(default!, default!);
}
[TestMethod]
public void HandleException_OperationCanceledException_IsHandled()
{
var handled = this.handler.HandleException(new OperationCanceledException());
handled.Should().BeTrue();
this.notificationService.DidNotReceiveWithAnyArgs().NotifyError<MessageBoxHandler>(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<MessageBoxHandler>(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<MessageBoxHandler>(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<MessageBoxHandler>(
nameof(InvalidOperationException),
Arg.Is<string>(s => s.Contains("boom")),
Arg.Any<string?>(), Arg.Any<DateTime?>(), Arg.Any<bool>(), Arg.Any<bool>());
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);
}
}
}
}
@@ -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<LaunchConfigurationServiceOptions> liveOptions = Substitute.For<IOptionsMonitor<LaunchConfigurationServiceOptions>>();
private readonly IOptionsProvider optionsProvider = Substitute.For<IOptionsProvider>();
private readonly ICredentialManager credentialManager = Substitute.For<ICredentialManager>();
private readonly IGuildWarsExecutableManager executableManager = Substitute.For<IGuildWarsExecutableManager>();
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<LoginCredentials?>())
.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<object>());
}
[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<object>());
}
[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<object>());
}
[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<LaunchConfigurationWithCredentials>
{
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();
}
}
@@ -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<string, MenuCategory> categories;
private readonly IServiceProvider serviceProvider = Substitute.For<IServiceProvider>();
private readonly MenuService service;
public MenuServiceTests()
{
var settings = new MenuCategoryBuilder("Settings").Build();
this.categories = new Dictionary<string, MenuCategory> { ["Settings"] = settings };
this.service = new MenuService(this.categories, this.serviceProvider, Substitute.For<ILogger<MenuService>>());
}
[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)!;
}
}
}
@@ -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);
}
}
@@ -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<INotificationStorage>();
private readonly IServiceProvider serviceProvider = Substitute.For<IServiceProvider>();
private readonly TaskCompletionSource<NotificationDTO> storedDto = new();
private readonly NotificationService service;
public NotificationServiceTests()
{
this.storage.When(s => s.StoreNotification(Arg.Any<NotificationDTO>(), Arg.Any<CancellationToken>()))
.Do(call => this.storedDto.TrySetResult(call.Arg<NotificationDTO>()));
this.service = new NotificationService(
this.serviceProvider,
this.storage,
Substitute.For<ILogger<NotificationService>>());
}
[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<TestHandler>("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<INotificationHandler>))
.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<CancellationToken>()).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<CancellationToken>());
}
private sealed class TestHandler : INotificationHandler
{
public Notification? OpenedWith { get; private set; }
public void OpenNotification(Notification notification) => this.OpenedWith = notification;
}
}
@@ -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<ICredentialManager>();
private readonly IGuildWarsExecutableManager executableManager = Substitute.For<IGuildWarsExecutableManager>();
private readonly ILaunchConfigurationService launchConfigurationService = Substitute.For<ILaunchConfigurationService>();
private readonly OnboardingService service;
public OnboardingServiceTests()
{
this.service = new OnboardingService(
this.credentialManager,
this.executableManager,
this.launchConfigurationService,
Substitute.For<ILogger<OnboardingService>>());
}
[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);
}
}
@@ -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<ILogger<StartupActionManager>>());
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<ILogger<StartupActionManager>>();
var manager = new StartupActionManager([ok, failing], logger);
Func<Task> 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<ILogger<StartupActionManager>>());
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"));
}
}