mirror of
https://github.com/gwdevhub/Daybreak.git
synced 2026-07-15 15:19:57 +00:00
Add more testing coverage (#1543)
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user