From 1b0bbb46503e9f601b8f41ca85909de28abd0f3c Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sun, 6 Jul 2025 10:06:50 -0700 Subject: [PATCH 01/53] Upgrade to .NET 9.0 and refactor codebase - Upgraded project files to .NET 9.0. - Updated package references for compatibility. - Refactored `TestChannel`, `TestGuild`, and various command classes to use constructor parameters. - Enhanced nullability handling and improved property initializations. - Simplified `MockGaiusAPIService` and `GaiusAPIService` initialization logic. - Changed `Snowflake` from a struct to a record for better usability. - Cleaned up unused methods in `Utilities` class. - Overall improvements in code clarity, maintainability, and adherence to modern .NET practices. --- .../InstarBot.Tests.Common.csproj | 8 +- InstarBot.Tests.Common/Models/TestChannel.cs | 97 +++++++---------- InstarBot.Tests.Common/Models/TestGuild.cs | 6 +- .../Models/TestGuildUser.cs | 18 ++++ InstarBot.Tests.Common/Models/TestMessage.cs | 55 +++++----- InstarBot.Tests.Common/Models/TestRole.cs | 10 +- .../Services/MockGaiusAPIService.cs | 41 +++---- .../Services/MockInstarDDBService.cs | 12 +-- .../Services/MockMetricService.cs | 2 +- InstarBot.Tests.Common/TestContext.cs | 8 +- InstarBot.Tests.Common/TestUtilities.cs | 39 ++++--- .../Features/AutoMemberSystem.feature | 1 - InstarBot.Tests.Integration/Hooks/Hook.cs | 17 +-- .../InstarBot.Tests.Integration.csproj | 16 +-- .../Steps/AutoMemberSystemStepDefinitions.cs | 102 +++++++++--------- .../Steps/BirthdayCommandStepDefinitions.cs | 39 +++---- .../Steps/PageCommandStepDefinitions.cs | 61 +++++------ .../Steps/PingCommandStepDefinitions.cs | 11 +- .../Steps/ReportUserCommandStepDefinitions.cs | 55 +++++----- .../InstarBot.Tests.Unit.csproj | 16 +-- .../RequireStaffMemberAttributeTests.cs | 16 +-- InstarBot.sln.DotSettings | 17 ++- InstarBot/AsyncEvent.cs | 9 +- InstarBot/Caching/MemoryCache.cs | 5 +- InstarBot/Commands/CheckEligibilityCommand.cs | 28 ++--- InstarBot/Commands/PageCommand.cs | 19 +--- InstarBot/Commands/ReportUserCommand.cs | 18 +--- InstarBot/Commands/SetBirthdayCommand.cs | 71 ++++++------ .../TriggerAutoMemberSystemCommand.cs | 11 +- InstarBot/ConfigurationException.cs | 7 +- InstarBot/Gaius/Caselog.cs | 6 +- InstarBot/Gaius/Warning.cs | 8 +- InstarBot/InstarBot.csproj | 38 +++---- InstarBot/MessageProperties.cs | 15 +-- InstarBot/Metrics/MetricDimensionAttribute.cs | 12 +-- InstarBot/Metrics/MetricNameAttribute.cs | 9 +- InstarBot/PageTarget.cs | 3 +- InstarBot/Program.cs | 11 +- InstarBot/Services/AWSDynamicConfigService.cs | 2 +- InstarBot/Services/AutoMemberSystem.cs | 23 ++-- InstarBot/Services/CloudwatchMetricService.cs | 5 +- InstarBot/Services/DiscordService.cs | 8 +- InstarBot/Services/GaiusAPIService.cs | 27 ++--- InstarBot/Services/InstarDDBService.cs | 4 +- InstarBot/Services/TeamService.cs | 19 ++-- InstarBot/Snowflake.cs | 2 +- InstarBot/SnowflakeTypeAttribute.cs | 9 +- InstarBot/TeamRefAttribute.cs | 9 +- .../MessageCommandInteractionWrapper.cs | 17 +-- InstarBot/Wrappers/SocketGuildWrapper.cs | 9 +- 50 files changed, 455 insertions(+), 596 deletions(-) diff --git a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj index 4d6c31c..90dc105 100644 --- a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj +++ b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj @@ -1,7 +1,7 @@ - net6.0 + net9.0 enable enable InstarBot.Tests @@ -10,9 +10,9 @@ - - - + + + diff --git a/InstarBot.Tests.Common/Models/TestChannel.cs b/InstarBot.Tests.Common/Models/TestChannel.cs index b167901..ea9224b 100644 --- a/InstarBot.Tests.Common/Models/TestChannel.cs +++ b/InstarBot.Tests.Common/Models/TestChannel.cs @@ -9,16 +9,10 @@ namespace InstarBot.Tests.Models; [SuppressMessage("ReSharper", "ReplaceAutoPropertyWithComputedProperty")] -public sealed class TestChannel : ITextChannel +public sealed class TestChannel(Snowflake id) : ITextChannel { - public TestChannel(Snowflake id) - { - Id = id; - CreatedAt = id.Time; - } - - public ulong Id { get; } - public DateTimeOffset CreatedAt { get; } + public ulong Id { get; } = id; + public DateTimeOffset CreatedAt { get; } = id.Time; public Task ModifyAsync(Action func, RequestOptions options = null) { @@ -66,10 +60,10 @@ Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode, RequestOpt throw new NotImplementedException(); } - public int Position { get; } = default!; + public int Position { get; } = 0; public ChannelFlags Flags { get; } = default!; public IGuild Guild { get; } = null!; - public ulong GuildId { get; } = default!; + public ulong GuildId { get; } = 0; public IReadOnlyCollection PermissionOverwrites { get; } = null!; IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) @@ -84,50 +78,6 @@ Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions optio public string Name { get; } = null!; - public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, - RequestOptions options = null, - AllowedMentions allowedMentions = null, MessageReference messageReference = null, - MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, - MessageFlags flags = MessageFlags.None) - { - throw new NotImplementedException(); - } - - public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, - RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, - MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, - Embed[] embeds = null, MessageFlags flags = MessageFlags.None) - { - throw new NotImplementedException(); - } - - public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, - Embed embed = null, - RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, - MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, - Embed[] embeds = null, MessageFlags flags = MessageFlags.None) - { - throw new NotImplementedException(); - } - - public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, - Embed embed = null, - RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, - MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, - MessageFlags flags = MessageFlags.None) - { - throw new NotImplementedException(); - } - - public Task SendFilesAsync(IEnumerable attachments, string text = null, - bool isTTS = false, Embed embed = null, - RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, - MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, - MessageFlags flags = MessageFlags.None) - { - throw new NotImplementedException(); - } - public Task GetMessageAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) { @@ -236,7 +186,7 @@ public Task> GetInvitesAsync(RequestOptions throw new NotImplementedException(); } - public ulong? CategoryId { get; } = default!; + public ulong? CategoryId { get; } = null; public Task CreateWebhookAsync(string name, Stream avatar = null, RequestOptions options = null) { @@ -280,15 +230,44 @@ public Task> GetActiveThreadsAsync(RequestOp throw new NotImplementedException(); } - public bool IsNsfw { get; } = default!; + public bool IsNsfw { get; } = false; public string Topic { get; } = null!; - public int SlowModeInterval { get; } = default!; + public int SlowModeInterval { get; } = 0; public ThreadArchiveDuration DefaultArchiveDuration { get; } = default!; - private readonly List _messages = new(); + public int DefaultSlowModeInterval => throw new NotImplementedException(); + + public ChannelType ChannelType => throw new NotImplementedException(); + + private readonly List _messages = []; public void AddMessage(IGuildUser user, string message) { _messages.Add(new TestMessage(user, message)); } + + public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + throw new NotImplementedException(); + } + + public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + throw new NotImplementedException(); + } + + public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + throw new NotImplementedException(); + } + + public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + throw new NotImplementedException(); + } + + public Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestGuild.cs b/InstarBot.Tests.Common/Models/TestGuild.cs index aba1a62..5aa293d 100644 --- a/InstarBot.Tests.Common/Models/TestGuild.cs +++ b/InstarBot.Tests.Common/Models/TestGuild.cs @@ -7,11 +7,11 @@ namespace InstarBot.Tests.Models; public class TestGuild : IInstarGuild { public ulong Id { get; init; } - public IEnumerable TextChannels { get; init; } = default!; + public IEnumerable TextChannels { get; init; } = null!; - public IEnumerable Roles { get; init; } = default!; + public IEnumerable Roles { get; init; } = null!; - public IEnumerable Users { get; init; } = default!; + public IEnumerable Users { get; init; } = null!; public virtual ITextChannel GetTextChannel(ulong channelId) { diff --git a/InstarBot.Tests.Common/Models/TestGuildUser.cs b/InstarBot.Tests.Common/Models/TestGuildUser.cs index e45ba37..7e1a21f 100644 --- a/InstarBot.Tests.Common/Models/TestGuildUser.cs +++ b/InstarBot.Tests.Common/Models/TestGuildUser.cs @@ -142,6 +142,16 @@ public Task RemoveTimeOutAsync(RequestOptions options = null) throw new NotImplementedException(); } + public string GetGuildBannerUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + { + throw new NotImplementedException(); + } + + public string GetAvatarDecorationUrl() + { + throw new NotImplementedException(); + } + public DateTimeOffset? JoinedAt { get; init; } public string DisplayName { get; set; } = null!; public string Nickname { get; set; } = null!; @@ -167,4 +177,12 @@ public IReadOnlyCollection RoleIds /// Test flag indicating the user has been changed. /// public bool Changed { get; private set; } + + public string GuildBannerHash => throw new NotImplementedException(); + + public string GlobalName => throw new NotImplementedException(); + + public string AvatarDecorationHash => throw new NotImplementedException(); + + public ulong? AvatarDecorationSkuId => throw new NotImplementedException(); } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestMessage.cs b/InstarBot.Tests.Common/Models/TestMessage.cs index 2d07119..6f51fbb 100644 --- a/InstarBot.Tests.Common/Models/TestMessage.cs +++ b/InstarBot.Tests.Common/Models/TestMessage.cs @@ -1,10 +1,8 @@ -using System.Diagnostics.CodeAnalysis; using Discord; using PaxAndromeda.Instar; namespace InstarBot.Tests.Models; -[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] public sealed class TestMessage : IMessage { @@ -51,38 +49,41 @@ public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions options throw new NotImplementedException(); } - public IAsyncEnumerable> GetReactionUsersAsync(IEmote emoji, int limit, - RequestOptions options = null!) + public IAsyncEnumerable> GetReactionUsersAsync(IEmote emoji, int limit, RequestOptions? options = null, ReactionType type = ReactionType.Normal) { throw new NotImplementedException(); } public MessageType Type { get; set; } = default; public MessageSource Source { get; set; } = default; - public bool IsTTS { get; set; } = default; - public bool IsPinned { get; set; } = default; - public bool IsSuppressed { get; set; } = default; - public bool MentionedEveryone { get; set; } = default; + public bool IsTTS { get; set; } = false; + public bool IsPinned { get; set; } = false; + public bool IsSuppressed { get; set; } = false; + public bool MentionedEveryone { get; set; } = false; public string Content { get; set; } - public string CleanContent { get; set; } = default!; + public string CleanContent { get; set; } = null!; public DateTimeOffset Timestamp { get; set; } - public DateTimeOffset? EditedTimestamp { get; set; } = default; - public IMessageChannel Channel { get; set; } = default!; + public DateTimeOffset? EditedTimestamp { get; set; } = null; + public IMessageChannel Channel { get; set; } = null!; public IUser Author { get; set; } - public IThreadChannel Thread { get; set; } = default!; - public IReadOnlyCollection Attachments { get; set; } = default!; - public IReadOnlyCollection Embeds { get; set; } = default!; - public IReadOnlyCollection Tags { get; set; } = default!; - public IReadOnlyCollection MentionedChannelIds { get; set; } = default!; - public IReadOnlyCollection MentionedRoleIds { get; set; } = default!; - public IReadOnlyCollection MentionedUserIds { get; set; } = default!; - public MessageActivity Activity { get; set; } = default!; - public MessageApplication Application { get; set; } = default!; - public MessageReference Reference { get; set; } = default!; - public IReadOnlyDictionary Reactions { get; set; } = default!; - public IReadOnlyCollection Components { get; set; } = default!; - public IReadOnlyCollection Stickers { get; set; } = default!; - public MessageFlags? Flags { get; set; } = default; - public IMessageInteraction Interaction { get; set; } = default!; - public MessageRoleSubscriptionData RoleSubscriptionData { get; set; } = default!; + public IThreadChannel Thread { get; set; } = null!; + public IReadOnlyCollection Attachments { get; set; } = null!; + public IReadOnlyCollection Embeds { get; set; } = null!; + public IReadOnlyCollection Tags { get; set; } = null!; + public IReadOnlyCollection MentionedChannelIds { get; set; } = null!; + public IReadOnlyCollection MentionedRoleIds { get; set; } = null!; + public IReadOnlyCollection MentionedUserIds { get; set; } = null!; + public MessageActivity Activity { get; set; } = null!; + public MessageApplication Application { get; set; } = null!; + public MessageReference Reference { get; set; } = null!; + public IReadOnlyDictionary Reactions { get; set; } = null!; + public IReadOnlyCollection Components { get; set; } = null!; + public IReadOnlyCollection Stickers { get; set; } = null!; + public MessageFlags? Flags { get; set; } = null; + public IMessageInteraction Interaction { get; set; } = null!; + public MessageRoleSubscriptionData RoleSubscriptionData { get; set; } = null!; + + public PurchaseNotification PurchaseNotification => throw new NotImplementedException(); + + public MessageCallData? CallData => throw new NotImplementedException(); } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestRole.cs b/InstarBot.Tests.Common/Models/TestRole.cs index 6bee06b..ae71435 100644 --- a/InstarBot.Tests.Common/Models/TestRole.cs +++ b/InstarBot.Tests.Common/Models/TestRole.cs @@ -42,13 +42,15 @@ public string GetIconUrl() public IGuild Guild { get; set; } = null!; public Color Color { get; set; } = default!; - public bool IsHoisted { get; set; } = default!; - public bool IsManaged { get; set; } = default!; - public bool IsMentionable { get; set; } = default!; + public bool IsHoisted { get; set; } = false; + public bool IsManaged { get; set; } = false; + public bool IsMentionable { get; set; } = false; public string Name { get; set; } = null!; public string Icon { get; set; } = null!; public Emoji Emoji { get; set; } = null!; public GuildPermissions Permissions { get; set; } = default!; - public int Position { get; set; } = default!; + public int Position { get; set; } = 0; public RoleTags Tags { get; set; } = null!; + + public RoleFlags Flags { get; set; } = default!; } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockGaiusAPIService.cs b/InstarBot.Tests.Common/Services/MockGaiusAPIService.cs index 6a58bd6..6ebc20a 100644 --- a/InstarBot.Tests.Common/Services/MockGaiusAPIService.cs +++ b/InstarBot.Tests.Common/Services/MockGaiusAPIService.cs @@ -4,19 +4,12 @@ namespace InstarBot.Tests.Services; -public sealed class MockGaiusAPIService : IGaiusAPIService +public sealed class MockGaiusAPIService( + Dictionary> warnings, + Dictionary> caselogs, + bool inhibit = false) + : IGaiusAPIService { - private readonly Dictionary> _warnings; - private readonly Dictionary> _caselogs; - private readonly bool _inhibit; - - public MockGaiusAPIService(Dictionary> warnings, Dictionary> caselogs, bool inhibit = false) - { - _warnings = warnings; - _caselogs = caselogs; - _inhibit = inhibit; - } - public void Dispose() { // do nothing @@ -24,41 +17,41 @@ public void Dispose() public Task> GetAllWarnings() { - return Task.FromResult>(_warnings.Values.SelectMany(list => list).ToList()); + return Task.FromResult>(warnings.Values.SelectMany(list => list).ToList()); } public Task> GetAllCaselogs() { - return Task.FromResult>(_caselogs.Values.SelectMany(list => list).ToList()); + return Task.FromResult>(caselogs.Values.SelectMany(list => list).ToList()); } public Task> GetWarningsAfter(DateTime dt) { - return Task.FromResult>(from list in _warnings.Values from item in list where item.WarnDate > dt select item); + return Task.FromResult>(from list in warnings.Values from item in list where item.WarnDate > dt select item); } public Task> GetCaselogsAfter(DateTime dt) { - return Task.FromResult>(from list in _caselogs.Values from item in list where item.Date > dt select item); + return Task.FromResult>(from list in caselogs.Values from item in list where item.Date > dt select item); } public Task?> GetWarnings(Snowflake userId) { - if (_inhibit) + if (inhibit) return Task.FromResult?>(null); - return !_warnings.ContainsKey(userId) - ? Task.FromResult?>(Array.Empty()) - : Task.FromResult?>(_warnings[userId]); + return !warnings.TryGetValue(userId, out var warning) + ? Task.FromResult?>([]) + : Task.FromResult?>(warning); } public Task?> GetCaselogs(Snowflake userId) { - if (_inhibit) + if (inhibit) return Task.FromResult?>(null); - return !_caselogs.ContainsKey(userId) - ? Task.FromResult?>(Array.Empty()) - : Task.FromResult?>(_caselogs[userId]); + return !caselogs.TryGetValue(userId, out var caselog) + ? Task.FromResult?>([]) + : Task.FromResult?>(caselog); } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs index 61c3004..f9d9676 100644 --- a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs +++ b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs @@ -44,22 +44,22 @@ public Task UpdateUserMembership(Snowflake snowflake, bool membershipGrant public Task GetUserBirthday(Snowflake snowflake) { - return !_localData.ContainsKey(snowflake) + return !_localData.TryGetValue(snowflake, out var value) ? Task.FromResult(null) - : Task.FromResult(_localData[snowflake].Birthday); + : Task.FromResult(value.Birthday); } public Task GetUserJoinDate(Snowflake snowflake) { - return !_localData.ContainsKey(snowflake) + return !_localData.TryGetValue(snowflake, out var value) ? Task.FromResult(null) - : Task.FromResult(_localData[snowflake].JoinDate); + : Task.FromResult(value.JoinDate); } public Task GetUserMembership(Snowflake snowflake) { - return !_localData.ContainsKey(snowflake) + return !_localData.TryGetValue(snowflake, out var value) ? Task.FromResult(null) - : Task.FromResult(_localData[snowflake].GrantedMembership); + : Task.FromResult(value.GrantedMembership); } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockMetricService.cs b/InstarBot.Tests.Common/Services/MockMetricService.cs index 46817d5..4a9c115 100644 --- a/InstarBot.Tests.Common/Services/MockMetricService.cs +++ b/InstarBot.Tests.Common/Services/MockMetricService.cs @@ -6,7 +6,7 @@ namespace InstarBot.Tests.Services; public sealed class MockMetricService : IMetricService { - private readonly List<(Metric, double)> _emittedMetrics = new(); + private readonly List<(Metric, double)> _emittedMetrics = []; public Task Emit(Metric metric, double value) { diff --git a/InstarBot.Tests.Common/TestContext.cs b/InstarBot.Tests.Common/TestContext.cs index 0dcf8c5..387c2dc 100644 --- a/InstarBot.Tests.Common/TestContext.cs +++ b/InstarBot.Tests.Common/TestContext.cs @@ -14,13 +14,13 @@ public sealed class TestContext public ulong ChannelID { get; init; } = 1420070400200; public ulong GuildID { get; init; } = 1420070400300; - public List UserRoles { get; init; } = new(); + public List UserRoles { get; init; } = []; public Action EmbedCallback { get; init; } = _ => { }; public Mock TextChannelMock { get; internal set; } = null!; - public List GuildUsers { get; init; } = new(); + public List GuildUsers { get; init; } = []; public Dictionary Channels { get; init; } = new(); public Dictionary Roles { get; init; } = new(); @@ -33,7 +33,7 @@ public sealed class TestContext public void AddWarning(Snowflake userId, Warning warning) { if (!Warnings.ContainsKey(userId)) - Warnings.Add(userId, new List { warning }); + Warnings.Add(userId, [warning]); else Warnings[userId].Add(warning); } @@ -41,7 +41,7 @@ public void AddWarning(Snowflake userId, Warning warning) public void AddCaselog(Snowflake userId, Caselog caselog) { if (!Caselogs.ContainsKey(userId)) - Caselogs.Add(userId, new List { caselog }); + Caselogs.Add(userId, [caselog]); else Caselogs[userId].Add(caselog); } diff --git a/InstarBot.Tests.Common/TestUtilities.cs b/InstarBot.Tests.Common/TestUtilities.cs index 4f8416c..5d3b918 100644 --- a/InstarBot.Tests.Common/TestUtilities.cs +++ b/InstarBot.Tests.Common/TestUtilities.cs @@ -65,9 +65,9 @@ public static IServiceProvider GetServices() } /// - /// Provides an method for verifying messages with an ambiguous Mock type. + /// Provides a method for verifying messages with an ambiguous Mock type. /// - /// The mock of the command. + /// A mockup of the command. /// The message to search for. /// A flag indicating whether the message should be ephemeral. public static void VerifyMessage(object mockObject, string message, bool ephemeral = false) @@ -92,13 +92,13 @@ public static void VerifyMessage(object mockObject, string message, bool ephemer .First(); var specificMethod = genericVerifyMessage.MakeGenericMethod(commandType); - specificMethod.Invoke(null, new[] { mockObject, message, ephemeral }); + specificMethod.Invoke(null, [mockObject, message, ephemeral]); } /// /// Verifies that the command responded to the user with the correct . /// - /// The mock of the command. + /// A mockup of the command. /// The message to check for. /// A flag indicating whether the message should be ephemeral. /// The type of command. Must implement . @@ -109,7 +109,7 @@ public static void VerifyMessage(Mock command, string message, bool epheme "RespondAsync", Times.Once(), message, ItExpr.IsAny(), false, ephemeral, ItExpr.IsAny(), ItExpr.IsAny(), - ItExpr.IsAny(), ItExpr.IsAny()); + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } public static IDiscordService SetupDiscordService(TestContext context = null!) @@ -118,7 +118,7 @@ public static IDiscordService SetupDiscordService(TestContext context = null!) public static IGaiusAPIService SetupGaiusAPIService(TestContext context = null!) => new MockGaiusAPIService(context.Warnings, context.Caselogs, context.InhibitGaius); - private static IInstarGuild SetupGuild(TestContext context = null!) + private static TestGuild SetupGuild(TestContext context = null!) { var guild = new TestGuild { @@ -162,7 +162,8 @@ private static void ConfigureCommandMock(Mock mock, TestContext? context) It.IsAny(), It.IsAny(), ItExpr.IsNull(), ItExpr.IsNull(), ItExpr.IsNull(), - ItExpr.IsNull()) + ItExpr.IsNull(), + ItExpr.IsNull()) .Returns(Task.CompletedTask); } @@ -170,10 +171,10 @@ public static Mock SetupContext(TestContext? context) { var mock = new Mock(); - mock.SetupGet(n => n.User!).Returns(SetupUserMock(context).Object); - mock.SetupGet(n => n.Channel!).Returns(SetupChannelMock(context).Object); + mock.SetupGet(static n => n.User!).Returns(SetupUserMock(context).Object); + mock.SetupGet(static n => n.Channel!).Returns(SetupChannelMock(context).Object); // Note: The following line must occur after the mocking of GetChannel. - mock.SetupGet(n => n.Guild).Returns(SetupGuildMock(context).Object); + mock.SetupGet(static n => n.Guild).Returns(SetupGuildMock(context).Object); return mock; } @@ -183,7 +184,7 @@ private static Mock SetupGuildMock(TestContext? context) context.Should().NotBeNull(); var guildMock = new Mock(); - guildMock.Setup(n => n.Id).Returns(context!.GuildID); + guildMock.Setup(n => n.Id).Returns(context.GuildID); guildMock.Setup(n => n.GetTextChannel(It.IsAny())) .Returns(context.TextChannelMock.Object); @@ -230,12 +231,16 @@ private static Mock SetupChannelMock(TestContext? context) channelMock.As().Setup(n => n.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), + It.IsAny(), + It.IsAny())) .Callback((string _, bool _, Embed embed, RequestOptions _, AllowedMentions _, MessageReference _, MessageComponent _, ISticker[] _, Embed[] _, - MessageFlags _) => + MessageFlags _, PollProperties _) => { context.EmbedCallback(embed); }) @@ -254,14 +259,14 @@ public static async IAsyncEnumerable GetTeams(PageTarget pageTarget) teamsConfig.Should().NotBeNull(); var teamRefs = pageTarget.GetAttributesOfType()?.Select(n => n.InternalID) ?? - new List(); + []; foreach (var internalId in teamRefs) { - if (!teamsConfig.ContainsKey(internalId)) + if (!teamsConfig.TryGetValue(internalId, out Team? value)) throw new KeyNotFoundException("Failed to find team with internal ID " + internalId); - yield return teamsConfig[internalId]; + yield return value; } } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Features/AutoMemberSystem.feature b/InstarBot.Tests.Integration/Features/AutoMemberSystem.feature index c59ada2..11c0f80 100644 --- a/InstarBot.Tests.Integration/Features/AutoMemberSystem.feature +++ b/InstarBot.Tests.Integration/Features/AutoMemberSystem.feature @@ -78,7 +78,6 @@ Feature: Auto Member System Then the user should remain unchanged Rule: Users should have all minimum requirements for membership - Scenario: A user that did not post an introduction should not be granted membership Given a user that has: * Joined 36 hours ago diff --git a/InstarBot.Tests.Integration/Hooks/Hook.cs b/InstarBot.Tests.Integration/Hooks/Hook.cs index 02d4810..7cbb8d2 100644 --- a/InstarBot.Tests.Integration/Hooks/Hook.cs +++ b/InstarBot.Tests.Integration/Hooks/Hook.cs @@ -3,28 +3,21 @@ namespace InstarBot.Tests.Integration.Hooks; [Binding] -public class InstarHooks +public class InstarHooks(ScenarioContext scenarioContext) { - private readonly ScenarioContext _scenarioContext; - - public InstarHooks(ScenarioContext scenarioContext) - { - _scenarioContext = scenarioContext; - } - [Then(@"Instar should emit a message stating ""(.*)""")] public void ThenInstarShouldEmitAMessageStating(string message) { - Assert.True(_scenarioContext.ContainsKey("Command")); - var cmdObject = _scenarioContext.Get("Command"); + Assert.True(scenarioContext.ContainsKey("Command")); + var cmdObject = scenarioContext.Get("Command"); TestUtilities.VerifyMessage(cmdObject, message); } [Then(@"Instar should emit an ephemeral message stating ""(.*)""")] public void ThenInstarShouldEmitAnEphemeralMessageStating(string message) { - Assert.True(_scenarioContext.ContainsKey("Command")); - var cmdObject = _scenarioContext.Get("Command"); + Assert.True(scenarioContext.ContainsKey("Command")); + var cmdObject = scenarioContext.Get("Command"); TestUtilities.VerifyMessage(cmdObject, message, true); } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj index 138202f..4f762d8 100644 --- a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj +++ b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj @@ -1,7 +1,7 @@ - net6.0 + net9.0 enable enable @@ -11,17 +11,17 @@ - - - + + + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs index 081b85e..8174ff6 100644 --- a/InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs +++ b/InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs @@ -10,16 +10,10 @@ namespace InstarBot.Tests.Integration; [Binding] -public class AutoMemberSystemStepDefinitions +public class AutoMemberSystemStepDefinitions(ScenarioContext scenarioContext) { - private readonly ScenarioContext _scenarioContext; private readonly Dictionary _roleNameIDMap = new(); - public AutoMemberSystemStepDefinitions(ScenarioContext scenarioContext) - { - _scenarioContext = scenarioContext; - } - [Given("the roles as follows:")] public void GivenTheRolesAsFollows(Table table) { @@ -31,21 +25,21 @@ public void GivenTheRolesAsFollows(Table table) private async Task SetupTest() { - var context = _scenarioContext.Get("Context"); + var context = scenarioContext.Get("Context"); var discordService = TestUtilities.SetupDiscordService(context); var gaiusApiService = TestUtilities.SetupGaiusAPIService(context); var config = TestUtilities.GetDynamicConfiguration(); - _scenarioContext.Add("Config", config); - _scenarioContext.Add("DiscordService", discordService); + scenarioContext.Add("Config", config); + scenarioContext.Add("DiscordService", discordService); - var userId = _scenarioContext.Get("UserID"); - var relativeJoinTime = _scenarioContext.Get("UserAge"); - var roles = _scenarioContext.Get("UserRoles").Select(roleId => new Snowflake(roleId)).ToArray(); - var postedIntro = _scenarioContext.Get("UserPostedIntroduction"); - var messagesLast24Hours = _scenarioContext.Get("UserMessagesPast24Hours"); - var firstSeenTime = _scenarioContext.ContainsKey("UserFirstJoinedTime") ? _scenarioContext.Get("UserFirstJoinedTime") : 0; - var grantedMembershipBefore = _scenarioContext.ContainsKey("UserGrantedMembershipBefore") && _scenarioContext.Get("UserGrantedMembershipBefore"); - var amsConfig = _scenarioContext.Get("AMSConfig"); + var userId = scenarioContext.Get("UserID"); + var relativeJoinTime = scenarioContext.Get("UserAge"); + var roles = scenarioContext.Get("UserRoles").Select(roleId => new Snowflake(roleId)).ToArray(); + var postedIntro = scenarioContext.Get("UserPostedIntroduction"); + var messagesLast24Hours = scenarioContext.Get("UserMessagesPast24Hours"); + var firstSeenTime = scenarioContext.ContainsKey("UserFirstJoinedTime") ? scenarioContext.Get("UserFirstJoinedTime") : 0; + var grantedMembershipBefore = scenarioContext.ContainsKey("UserGrantedMembershipBefore") && scenarioContext.Get("UserGrantedMembershipBefore"); + var amsConfig = scenarioContext.Get("AMSConfig"); var ddbService = new MockInstarDDBService(); if (firstSeenTime > 0) @@ -77,8 +71,8 @@ private async Task SetupTest() var ams = new AutoMemberSystem(config, discordService, gaiusApiService, ddbService, new MockMetricService()); - _scenarioContext.Add("AutoMemberSystem", ams); - _scenarioContext.Add("User", user); + scenarioContext.Add("AutoMemberSystem", ams); + scenarioContext.Add("User", user); return ams; } @@ -94,19 +88,19 @@ public async Task WhenTheAutoMemberSystemProcesses() [Then("the user should remain unchanged")] public void ThenTheUserShouldRemainUnchanged() { - var userId = _scenarioContext.Get("UserID"); - var context = _scenarioContext.Get("Context"); + var userId = scenarioContext.Get("UserID"); + var context = scenarioContext.Get("Context"); var user = context.GuildUsers.First(n => n.Id == userId.ID) as TestGuildUser; user.Should().NotBeNull(); - user!.Changed.Should().BeFalse(); + user.Changed.Should().BeFalse(); } [Given("Been issued a warning")] public void GivenBeenIssuedAWarning() { - var userId = _scenarioContext.Get("UserID"); - var context = _scenarioContext.Get("Context"); + var userId = scenarioContext.Get("UserID"); + var context = scenarioContext.Get("Context"); context.AddWarning(userId, new Warning { @@ -119,8 +113,8 @@ public void GivenBeenIssuedAWarning() [Given("Been issued a mute")] public void GivenBeenIssuedAMute() { - var userId = _scenarioContext.Get("UserID"); - var context = _scenarioContext.Get("Context"); + var userId = scenarioContext.Get("UserID"); + var context = scenarioContext.Get("Context"); context.AddCaselog(userId, new Caselog { @@ -134,7 +128,7 @@ public void GivenBeenIssuedAMute() [Given("the Gaius API is not available")] public void GivenTheGaiusApiIsNotAvailable() { - var context = _scenarioContext.Get("Context"); + var context = scenarioContext.Get("Context"); context.InhibitGaius = true; } @@ -143,44 +137,44 @@ public async Task GivenAUserThatHas() { var config = await TestUtilities.GetDynamicConfiguration().GetConfig(); var amsConfig = config.AutoMemberConfig; - _scenarioContext.Add("AMSConfig", amsConfig); + scenarioContext.Add("AMSConfig", amsConfig); var cmc = new TestContext(); - _scenarioContext.Add("Context", cmc); + scenarioContext.Add("Context", cmc); var userId = Snowflake.Generate(); - _scenarioContext.Add("UserID", userId); + scenarioContext.Add("UserID", userId); } [Given("Joined (.*) hours ago")] - public void GivenJoinedHoursAgo(int ageHours) => _scenarioContext.Add("UserAge", ageHours); + public void GivenJoinedHoursAgo(int ageHours) => scenarioContext.Add("UserAge", ageHours); [Given("The roles (.*)")] public void GivenTheRoles(string roles) { - var roleNames = roles.Split(new[] { ",", "and" }, + var roleNames = roles.Split([",", "and"], StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); var roleIds = roleNames.Select(roleName => _roleNameIDMap[roleName]).ToArray(); - _scenarioContext.Add("UserRoles", roleIds); + scenarioContext.Add("UserRoles", roleIds); } [Given("Posted an introduction")] - public void GivenPostedAnIntroduction() => _scenarioContext.Add("UserPostedIntroduction", true); + public void GivenPostedAnIntroduction() => scenarioContext.Add("UserPostedIntroduction", true); [Given("Did not post an introduction")] - public void GivenDidNotPostAnIntroduction() => _scenarioContext.Add("UserPostedIntroduction", false); + public void GivenDidNotPostAnIntroduction() => scenarioContext.Add("UserPostedIntroduction", false); [Given("Posted (.*) messages in the past day")] - public void GivenPostedMessagesInThePastDay(int numMessages) => _scenarioContext.Add("UserMessagesPast24Hours", numMessages); + public void GivenPostedMessagesInThePastDay(int numMessages) => scenarioContext.Add("UserMessagesPast24Hours", numMessages); [Then("the user should be granted membership")] public async Task ThenTheUserShouldBeGrantedMembership() { - var userId = _scenarioContext.Get("UserID"); - var context = _scenarioContext.Get("Context"); - var config = _scenarioContext.Get("Config"); + var userId = scenarioContext.Get("UserID"); + var context = scenarioContext.Get("Context"); + var config = scenarioContext.Get("Config"); var user = context.GuildUsers.First(n => n.Id == userId.ID); var cfg = await config.GetConfig(); @@ -192,9 +186,9 @@ public async Task ThenTheUserShouldBeGrantedMembership() [Then("the user should not be granted membership")] public async Task ThenTheUserShouldNotBeGrantedMembership() { - var userId = _scenarioContext.Get("UserID"); - var context = _scenarioContext.Get("Context"); - var config = _scenarioContext.Get("Config"); + var userId = scenarioContext.Get("UserID"); + var context = scenarioContext.Get("Context"); + var config = scenarioContext.Get("Config"); var user = context.GuildUsers.First(n => n.Id == userId.ID); var cfg = await config.GetConfig(); @@ -209,24 +203,24 @@ public void GivenNotBeenPunished() // ignore } - [Given(@"First joined (.*) hours ago")] - public void GivenFirstJoinedHoursAgo(int hoursAgo) => _scenarioContext.Add("UserFirstJoinedTime", hoursAgo); + [Given("First joined (.*) hours ago")] + public void GivenFirstJoinedHoursAgo(int hoursAgo) => scenarioContext.Add("UserFirstJoinedTime", hoursAgo); - [Given(@"Joined the server for the first time")] - public void GivenJoinedTheServerForTheFirstTime() => _scenarioContext.Add("UserFirstJoinedTime", 0); + [Given("Joined the server for the first time")] + public void GivenJoinedTheServerForTheFirstTime() => scenarioContext.Add("UserFirstJoinedTime", 0); - [Given(@"Been granted membership before")] - public void GivenBeenGrantedMembershipBefore() => _scenarioContext.Add("UserGrantedMembershipBefore", true); + [Given("Been granted membership before")] + public void GivenBeenGrantedMembershipBefore() => scenarioContext.Add("UserGrantedMembershipBefore", true); - [Given(@"Not been granted membership before")] - public void GivenNotBeenGrantedMembershipBefore() => _scenarioContext.Add("UserGrantedMembershipBefore", false); + [Given("Not been granted membership before")] + public void GivenNotBeenGrantedMembershipBefore() => scenarioContext.Add("UserGrantedMembershipBefore", false); - [When(@"the user joins the server")] + [When("the user joins the server")] public async Task WhenTheUserJoinsTheServer() { await SetupTest(); - var service = _scenarioContext.Get("DiscordService") as MockDiscordService; - var user = _scenarioContext.Get("User"); + var service = scenarioContext.Get("DiscordService") as MockDiscordService; + var user = scenarioContext.Get("User"); service?.TriggerUserJoined(user); } diff --git a/InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs index f77a20e..064ac00 100644 --- a/InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs +++ b/InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using FluentAssertions; using InstarBot.Tests.Services; using Microsoft.Extensions.DependencyInjection; @@ -11,15 +10,8 @@ namespace InstarBot.Tests.Integration; [Binding] -public class BirthdayCommandStepDefinitions +public class BirthdayCommandStepDefinitions(ScenarioContext context) { - private readonly ScenarioContext _context; - - public BirthdayCommandStepDefinitions(ScenarioContext context) - { - _context = context; - } - [Given("the user provides the following parameters")] public void GivenTheUserProvidesTheFollowingParameters(Table table) { @@ -28,21 +20,21 @@ public void GivenTheUserProvidesTheFollowingParameters(Table table) // Let's see if we have the bare minimum Assert.True(dict.ContainsKey("Year") && dict.ContainsKey("Month") && dict.ContainsKey("Day")); - _context.Add("Year", dict["Year"]); - _context.Add("Month", dict["Month"]); - _context.Add("Day", dict["Day"]); + context.Add("Year", dict["Year"]); + context.Add("Month", dict["Month"]); + context.Add("Day", dict["Day"]); if (dict.TryGetValue("Timezone", out var value)) - _context.Add("Timezone", value); + context.Add("Timezone", value); } [When("the user calls the Set Birthday command")] public async Task WhenTheUserCallsTheSetBirthdayCommand() { - var year = _context.Get("Year"); - var month = _context.Get("Month"); - var day = _context.Get("Day"); - var timezone = _context.ContainsKey("Timezone") ? _context.Get("Timezone") : 0; + var year = context.Get("Year"); + var month = context.Get("Month"); + var day = context.Get("Day"); + var timezone = context.ContainsKey("Timezone") ? context.Get("Timezone") : 0; var userId = new Snowflake().ID; @@ -51,19 +43,18 @@ public async Task WhenTheUserCallsTheSetBirthdayCommand() { UserID = userId }); - _context.Add("Command", cmd); - _context.Add("UserID", userId); - _context.Add("DDB", ddbService); + context.Add("Command", cmd); + context.Add("UserID", userId); + context.Add("DDB", ddbService); await cmd.Object.SetBirthday((Month)month, day, year, timezone); } [Then("DynamoDB should have the user's (Birthday|JoinDate) set to (.*)")] - [SuppressMessage("ReSharper", "SpecFlow.MethodNameMismatchPattern")] public async Task ThenDynamoDbShouldHaveBirthdaySetTo(string dataType, DateTime time) { - var ddbService = _context.Get("DDB"); - var userId = _context.Get("UserID"); + var ddbService = context.Get("DDB"); + var userId = context.Get("UserID"); switch (dataType) { @@ -74,7 +65,7 @@ public async Task ThenDynamoDbShouldHaveBirthdaySetTo(string dataType, DateTime (await ddbService!.GetUserJoinDate(userId)).Should().Be(time.ToUniversalTime()); break; default: - Assert.False(true, "Invalid test setup: dataType is unknown"); + Assert.Fail("Invalid test setup: dataType is unknown"); break; } } diff --git a/InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs index 52c0f90..23ca192 100644 --- a/InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs +++ b/InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs @@ -10,54 +10,47 @@ namespace InstarBot.Tests.Integration; [Binding] -public sealed class PageCommandStepDefinitions +public sealed class PageCommandStepDefinitions(ScenarioContext scenarioContext) { - private readonly ScenarioContext _scenarioContext; - - public PageCommandStepDefinitions(ScenarioContext scenarioContext) - { - _scenarioContext = scenarioContext; - } - [Given("the user is in team (.*)")] public async Task GivenTheUserIsInTeam(PageTarget target) { var team = await TestUtilities.GetTeams(target).FirstAsync(); - _scenarioContext.Add("UserTeamID", team.ID); + scenarioContext.Add("UserTeamID", team.ID); } [Given("the user is not a staff member")] public void GivenTheUserIsNotAStaffMember() { - _scenarioContext.Add("UserTeamID", new Snowflake()); + scenarioContext.Add("UserTeamID", new Snowflake()); } [Given("the user is paging (Helper|Moderator|Admin|Owner|Test|All)")] [SuppressMessage("ReSharper", "SpecFlow.MethodNameMismatchPattern")] public void GivenTheUserIsPaging(PageTarget target) { - _scenarioContext.Add("PageTarget", target); - _scenarioContext.Add("PagingTeamLeader", false); + scenarioContext.Add("PageTarget", target); + scenarioContext.Add("PagingTeamLeader", false); } [Given("the user is paging the (Helper|Moderator|Admin|Owner|Test|All) teamleader")] [SuppressMessage("ReSharper", "SpecFlow.MethodNameMismatchPattern")] public void GivenTheUserIsPagingTheTeamTeamleader(PageTarget target) { - _scenarioContext.Add("PageTarget", target); - _scenarioContext.Add("PagingTeamLeader", true); + scenarioContext.Add("PageTarget", target); + scenarioContext.Add("PagingTeamLeader", true); } [When("the user calls the Page command")] public async Task WhenTheUserCallsThePageCommand() { - _scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); - _scenarioContext.ContainsKey("PagingTeamLeader").Should().BeTrue(); - var pageTarget = _scenarioContext.Get("PageTarget"); - var pagingTeamLeader = _scenarioContext.Get("PagingTeamLeader"); + scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); + scenarioContext.ContainsKey("PagingTeamLeader").Should().BeTrue(); + var pageTarget = scenarioContext.Get("PageTarget"); + var pagingTeamLeader = scenarioContext.Get("PagingTeamLeader"); var command = SetupMocks(); - _scenarioContext.Add("Command", command); + scenarioContext.Add("Command", command); await command.Object.Page(pageTarget, "This is a test reason", pagingTeamLeader); } @@ -65,10 +58,10 @@ public async Task WhenTheUserCallsThePageCommand() [Then("Instar should emit a valid Page embed")] public async Task ThenInstarShouldEmitAValidPageEmbed() { - _scenarioContext.ContainsKey("Command").Should().BeTrue(); - _scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); - var command = _scenarioContext.Get>("Command"); - var pageTarget = _scenarioContext.Get("PageTarget"); + scenarioContext.ContainsKey("Command").Should().BeTrue(); + scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); + var command = scenarioContext.Get>("Command"); + var pageTarget = scenarioContext.Get("PageTarget"); string expectedString; @@ -86,23 +79,23 @@ public async Task ThenInstarShouldEmitAValidPageEmbed() "RespondAsync", Times.Once(), expectedString, ItExpr.IsNull(), false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny()); + ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); } [Then("Instar should emit a valid teamleader Page embed")] public async Task ThenInstarShouldEmitAValidTeamleaderPageEmbed() { - _scenarioContext.ContainsKey("Command").Should().BeTrue(); - _scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); + scenarioContext.ContainsKey("Command").Should().BeTrue(); + scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); - var command = _scenarioContext.Get>("Command"); - var pageTarget = _scenarioContext.Get("PageTarget"); + var command = scenarioContext.Get>("Command"); + var pageTarget = scenarioContext.Get("PageTarget"); command.Protected().Verify( "RespondAsync", Times.Once(), $"<@{await GetTeamLead(pageTarget)}>", ItExpr.IsNull(), false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny()); + ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); } private static async Task GetTeamLead(PageTarget pageTarget) @@ -120,8 +113,8 @@ private static async Task GetTeamLead(PageTarget pageTarget) [Then("Instar should emit a valid All Page embed")] public async Task ThenInstarShouldEmitAValidAllPageEmbed() { - _scenarioContext.ContainsKey("Command").Should().BeTrue(); - var command = _scenarioContext.Get>("Command"); + scenarioContext.ContainsKey("Command").Should().BeTrue(); + var command = scenarioContext.Get>("Command"); var expected = string.Join(' ', await TestUtilities.GetTeams(PageTarget.All).Select(n => Snowflake.GetMention(() => n.ID)).ToArrayAsync()); @@ -129,18 +122,18 @@ public async Task ThenInstarShouldEmitAValidAllPageEmbed() "RespondAsync", Times.Once(), expected, ItExpr.IsNull(), false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny()); + ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); } private Mock SetupMocks() { - var userTeam = _scenarioContext.Get("UserTeamID"); + var userTeam = scenarioContext.Get("UserTeamID"); var commandMock = TestUtilities.SetupCommandMock( () => new PageCommand(TestUtilities.GetTeamService(), new MockMetricService()), new TestContext { - UserRoles = new List { userTeam } + UserRoles = [userTeam] }); return commandMock; diff --git a/InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs index 1e76b23..ff96def 100644 --- a/InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs +++ b/InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs @@ -3,20 +3,13 @@ namespace InstarBot.Tests.Integration; [Binding] -public class PingCommandStepDefinitions +public class PingCommandStepDefinitions(ScenarioContext context) { - private readonly ScenarioContext _context; - - public PingCommandStepDefinitions(ScenarioContext context) - { - _context = context; - } - [When("the user calls the Ping command")] public async Task WhenTheUserCallsThePingCommand() { var command = TestUtilities.SetupCommandMock(); - _context.Add("Command", command); + context.Add("Command", command); await command.Object.Ping(); } diff --git a/InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs index c044cb5..ddb7a73 100644 --- a/InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs +++ b/InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs @@ -11,19 +11,12 @@ namespace InstarBot.Tests.Integration; [Binding] -public class ReportUserCommandStepDefinitions +public class ReportUserCommandStepDefinitions(ScenarioContext context) { - private readonly ScenarioContext _context; - - public ReportUserCommandStepDefinitions(ScenarioContext context) - { - _context = context; - } - [When("the user (.*) reports a message with the following properties")] public async Task WhenTheUserReportsAMessageWithTheFollowingProperties(ulong userId, Table table) { - _context.Add("ReportingUserID", userId); + context.Add("ReportingUserID", userId); var messageProperties = table.Rows.ToDictionary(n => n["Key"], n => n); @@ -31,13 +24,13 @@ public async Task WhenTheUserReportsAMessageWithTheFollowingProperties(ulong use Assert.True(messageProperties.ContainsKey("Sender")); Assert.True(messageProperties.ContainsKey("Channel")); - _context.Add("MessageContent", messageProperties["Content"].GetString("Value")); - _context.Add("MessageSender", (ulong)messageProperties["Sender"].GetInt64("Value")); - _context.Add("MessageChannel", (ulong)messageProperties["Channel"].GetInt64("Value")); + context.Add("MessageContent", messageProperties["Content"].GetString("Value")); + context.Add("MessageSender", (ulong)messageProperties["Sender"].GetInt64("Value")); + context.Add("MessageChannel", (ulong)messageProperties["Channel"].GetInt64("Value")); var (command, interactionContext) = SetupMocks(); - _context.Add("Command", command); - _context.Add("InteractionContext", interactionContext); + context.Add("Command", command); + context.Add("InteractionContext", interactionContext); await command.Object.HandleCommand(interactionContext.Object); } @@ -45,11 +38,11 @@ public async Task WhenTheUserReportsAMessageWithTheFollowingProperties(ulong use [When("does not complete the modal within 5 minutes")] public async Task WhenDoesNotCompleteTheModalWithinMinutes() { - Assert.True(_context.ContainsKey("Command")); - var command = _context.Get>("Command"); + Assert.True(context.ContainsKey("Command")); + var command = context.Get>("Command"); ReportUserCommand.PurgeCache(); - _context.Add("ReportReason", string.Empty); + context.Add("ReportReason", string.Empty); await command.Object.ModalResponse(new ReportMessageModal { @@ -60,9 +53,9 @@ await command.Object.ModalResponse(new ReportMessageModal [When(@"completes the report modal with reason ""(.*)""")] public async Task WhenCompletesTheReportModalWithReason(string reportReason) { - Assert.True(_context.ContainsKey("Command")); - var command = _context.Get>("Command"); - _context.Add("ReportReason", reportReason); + Assert.True(context.ContainsKey("Command")); + var command = context.Get>("Command"); + context.Add("ReportReason", reportReason); await command.Object.ModalResponse(new ReportMessageModal { @@ -73,17 +66,17 @@ await command.Object.ModalResponse(new ReportMessageModal [Then("Instar should emit a message report embed")] public void ThenInstarShouldEmitAMessageReportEmbed() { - Assert.True(_context.ContainsKey("TextChannelMock")); - var textChannel = _context.Get>("TextChannelMock"); + Assert.True(context.ContainsKey("TextChannelMock")); + var textChannel = context.Get>("TextChannelMock"); textChannel.Verify(n => n.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())); + It.IsAny(), It.IsAny(), It.IsAny())); - Assert.True(_context.ContainsKey("ResultEmbed")); - var embed = _context.Get("ResultEmbed"); + Assert.True(context.ContainsKey("ResultEmbed")); + var embed = context.Get("ResultEmbed"); embed.Author.Should().NotBeNull(); embed.Footer.Should().NotBeNull(); @@ -94,25 +87,25 @@ public void ThenInstarShouldEmitAMessageReportEmbed() { var commandMockContext = new TestContext { - UserID = _context.Get("ReportingUserID"), - EmbedCallback = embed => _context.Add("ResultEmbed", embed) + UserID = context.Get("ReportingUserID"), + EmbedCallback = embed => context.Add("ResultEmbed", embed) }; var commandMock = TestUtilities.SetupCommandMock (() => new ReportUserCommand(TestUtilities.GetDynamicConfiguration(), new MockMetricService()), commandMockContext); - _context.Add("TextChannelMock", commandMockContext.TextChannelMock); + context.Add("TextChannelMock", commandMockContext.TextChannelMock); return (commandMock, SetupMessageCommandMock()); } private Mock SetupMessageCommandMock() { - var userMock = TestUtilities.SetupUserMock(_context.Get("ReportingUserID")); - var authorMock = TestUtilities.SetupUserMock(_context.Get("MessageSender")); + var userMock = TestUtilities.SetupUserMock(context.Get("ReportingUserID")); + var authorMock = TestUtilities.SetupUserMock(context.Get("MessageSender")); - var channelMock = TestUtilities.SetupChannelMock(_context.Get("MessageChannel")); + var channelMock = TestUtilities.SetupChannelMock(context.Get("MessageChannel")); var messageMock = new Mock(); messageMock.Setup(n => n.Id).Returns(100); diff --git a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj index 7280a04..7dc1491 100644 --- a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj +++ b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj @@ -1,7 +1,7 @@ - net6.0 + net9.0 enable false @@ -10,16 +10,16 @@ - - - - - - + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs b/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs index 489a2fd..8a0f55f 100644 --- a/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs +++ b/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Moq; -using PaxAndromeda.Instar; using PaxAndromeda.Instar.Preconditions; using Xunit; @@ -25,10 +24,7 @@ public async Task CheckRequirementsAsync_ShouldReturnFalse_WithBadConfig() var context = TestUtilities.SetupContext(new TestContext { - UserRoles = new List - { - new(793607635608928257) - } + UserRoles = [new(793607635608928257)] }); // Act @@ -62,10 +58,7 @@ public async Task CheckRequirementsAsync_ShouldReturnSuccessful_WithValidUser() var context = TestUtilities.SetupContext(new TestContext { - UserRoles = new List - { - new(793607635608928257) - } + UserRoles = [new(793607635608928257)] }); // Act @@ -83,10 +76,7 @@ public async Task CheckRequirementsAsync_ShouldReturnFailure_WithNonStaffUser() var context = TestUtilities.SetupContext(new TestContext { - UserRoles = new List - { - new() - } + UserRoles = [new()] }); // Act diff --git a/InstarBot.sln.DotSettings b/InstarBot.sln.DotSettings index 9339f36..48c34d6 100644 --- a/InstarBot.sln.DotSettings +++ b/InstarBot.sln.DotSettings @@ -7,4 +7,19 @@ DDB ID TTS - URL \ No newline at end of file + URL + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True \ No newline at end of file diff --git a/InstarBot/AsyncEvent.cs b/InstarBot/AsyncEvent.cs index 78b9a1e..5c6bc33 100644 --- a/InstarBot/AsyncEvent.cs +++ b/InstarBot/AsyncEvent.cs @@ -5,13 +5,8 @@ namespace PaxAndromeda.Instar; internal sealed class AsyncEvent { - private readonly object _subLock = new(); - private ImmutableArray> _subscriptions; - - public AsyncEvent() - { - _subscriptions = ImmutableArray.Create>(); - } + private readonly Lock _subLock = new(); + private ImmutableArray> _subscriptions = []; public async Task Invoke(T parameter) { diff --git a/InstarBot/Caching/MemoryCache.cs b/InstarBot/Caching/MemoryCache.cs index c58ffdb..f61b53a 100644 --- a/InstarBot/Caching/MemoryCache.cs +++ b/InstarBot/Caching/MemoryCache.cs @@ -15,22 +15,19 @@ public MemoryCache(string name, NameValueCollection config, bool ignoreConfigSec { } - [SuppressMessage("ReSharper", "UnusedMember.Global")] public bool Add(string key, T value, DateTimeOffset absoluteExpiration, string regionName = null!) { return value != null && base.Add(key, value, absoluteExpiration, regionName); } - [SuppressMessage("ReSharper", "UnusedMethodReturnValue.Global")] public bool Add(string key, T value, CacheItemPolicy policy, string regionName = null!) { return value != null && base.Add(key, value, policy, regionName); } - [SuppressMessage("ReSharper", "UnusedMember.Global")] public new T Get(string key, string regionName = null!) { - return (T) base.Get(key, regionName); + return (T) base.Get(key, regionName) ?? throw new InvalidOperationException($"{nameof(key)} cannot be null"); } public new IEnumerator> GetEnumerator() diff --git a/InstarBot/Commands/CheckEligibilityCommand.cs b/InstarBot/Commands/CheckEligibilityCommand.cs index 66d8c7f..b8d97f0 100644 --- a/InstarBot/Commands/CheckEligibilityCommand.cs +++ b/InstarBot/Commands/CheckEligibilityCommand.cs @@ -3,8 +3,6 @@ using Discord; using Discord.Interactions; using JetBrains.Annotations; -using Microsoft.Extensions.Configuration; -using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Serilog; @@ -12,25 +10,17 @@ namespace PaxAndromeda.Instar.Commands; [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class CheckEligibilityCommand : BaseCommand +public class CheckEligibilityCommand( + IDynamicConfigService dynamicConfig, + AutoMemberSystem autoMemberSystem, + IMetricService metricService) + : BaseCommand { - private readonly IDynamicConfigService _dynamicConfig; - private readonly AutoMemberSystem _autoMemberSystem; - private readonly IMetricService _metricService; - - public CheckEligibilityCommand(IDynamicConfigService dynamicConfig, AutoMemberSystem autoMemberSystem, - IMetricService metricService) - { - _dynamicConfig = dynamicConfig; - _autoMemberSystem = autoMemberSystem; - _metricService = metricService; - } - [UsedImplicitly] [SlashCommand("checkeligibility", "This command checks your membership eligibility.")] public async Task CheckEligibility() { - var config = await _dynamicConfig.GetConfig(); + var config = await dynamicConfig.GetConfig(); if (Context.User is null) { @@ -50,7 +40,7 @@ public async Task CheckEligibility() return; } - var eligibility = await _autoMemberSystem.CheckEligibility(Context.User); + var eligibility = await autoMemberSystem.CheckEligibility(Context.User); Log.Debug("Building response embed..."); var fields = new List(); @@ -83,12 +73,12 @@ public async Task CheckEligibility() Log.Debug("Responding..."); await RespondAsync(embed: builder.Build(), ephemeral: true); - await _metricService.Emit(Metric.AMS_EligibilityCheck, 1); + await metricService.Emit(Metric.AMS_EligibilityCheck, 1); } private async Task BuildMissingItemsText(MembershipEligibility eligibility, IGuildUser user) { - var config = await _dynamicConfig.GetConfig(); + var config = await dynamicConfig.GetConfig(); if (eligibility == MembershipEligibility.Eligible) return string.Empty; diff --git a/InstarBot/Commands/PageCommand.cs b/InstarBot/Commands/PageCommand.cs index d9f41ae..7818e81 100644 --- a/InstarBot/Commands/PageCommand.cs +++ b/InstarBot/Commands/PageCommand.cs @@ -14,17 +14,8 @@ namespace PaxAndromeda.Instar.Commands; // Required to be unsealed for mocking [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] [SuppressMessage("ReSharper", "ClassWithVirtualMembersNeverInherited.Global")] // Required for mocking -public class PageCommand : BaseCommand +public class PageCommand(TeamService teamService, IMetricService metricService) : BaseCommand { - private readonly TeamService _teamService; - private readonly IMetricService _metricService; - - public PageCommand(TeamService teamService, IMetricService metricService) - { - _teamService = teamService; - _metricService = metricService; - } - [UsedImplicitly] [SlashCommand("page", "This command initiates a directed page.")] [RequireStaffMember] @@ -51,7 +42,7 @@ public async Task Page( { Log.Verbose("User {User} is attempting to page {Team}: {Reason}", Context.User.Id, team, reason); - var userTeam = await _teamService.GetUserPrimaryStaffTeam(Context.User); + var userTeam = await teamService.GetUserPrimaryStaffTeam(Context.User); if (!CheckPermissions(Context.User, userTeam, team, teamLead, out var response)) { await RespondAsync(response, ephemeral: true); @@ -62,9 +53,9 @@ public async Task Page( if (team == PageTarget.Test) mention = "This is a __**TEST**__ page."; else if (teamLead) - mention = await _teamService.GetTeamLeadMention(team); + mention = await teamService.GetTeamLeadMention(team); else - mention = await _teamService.GetTeamMention(team); + mention = await teamService.GetTeamMention(team); Log.Debug("Emitting page to {ChannelName}", Context.Channel?.Name); await RespondAsync( @@ -72,7 +63,7 @@ await RespondAsync( embed: BuildEmbed(reason, message, user, channel, userTeam!, Context.User), allowedMentions: AllowedMentions.All); - await _metricService.Emit(Metric.Paging_SentPages, 1); + await metricService.Emit(Metric.Paging_SentPages, 1); } catch (Exception ex) { diff --git a/InstarBot/Commands/ReportUserCommand.cs b/InstarBot/Commands/ReportUserCommand.cs index 610b61b..79fcefa 100644 --- a/InstarBot/Commands/ReportUserCommand.cs +++ b/InstarBot/Commands/ReportUserCommand.cs @@ -11,10 +11,8 @@ namespace PaxAndromeda.Instar.Commands; // Required to be unsealed for mocking [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class ReportUserCommand : BaseCommand, IContextCommand +public class ReportUserCommand(IDynamicConfigService dynamicConfig, IMetricService metricService) : BaseCommand, IContextCommand { - private readonly IDynamicConfigService _dynamicConfig; - private readonly IMetricService _metricService; private const string ModalId = "respond_modal"; private static readonly MemoryCache Cache = new("User Report Cache"); @@ -25,12 +23,6 @@ internal static void PurgeCache() Cache.Remove(n.Key, CacheEntryRemovedReason.Removed); } - public ReportUserCommand(IDynamicConfigService dynamicConfig, IMetricService metricService) - { - _dynamicConfig = dynamicConfig; - _metricService = metricService; - } - [ExcludeFromCodeCoverage(Justification = "Constant used for mapping")] public string Name => "Report Message"; @@ -59,9 +51,9 @@ public MessageCommandProperties CreateCommand() [ModalInteraction(ModalId)] public async Task ModalResponse(ReportMessageModal modal) { - var message = (IMessage)Cache.Get(Context.User!.Id.ToString()); + var message = (IMessage?) Cache.Get(Context.User!.Id.ToString()); // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - if (message == null) + if (message is null) { await RespondAsync("Report expired. Please try again.", ephemeral: true); return; @@ -74,7 +66,7 @@ public async Task ModalResponse(ReportMessageModal modal) private async Task SendReportMessage(ReportMessageModal modal, IMessage message, IInstarGuild guild) { - var cfg = await _dynamicConfig.GetConfig(); + var cfg = await dynamicConfig.GetConfig(); var fields = new List { @@ -124,6 +116,6 @@ private async Task SendReportMessage(ReportMessageModal modal, IMessage message, Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel) .SendMessageAsync(staffPing, embed: builder.Build()); - await _metricService.Emit(Metric.ReportUser_ReportsSent, 1); + await metricService.Emit(Metric.ReportUser_ReportsSent, 1); } } \ No newline at end of file diff --git a/InstarBot/Commands/SetBirthdayCommand.cs b/InstarBot/Commands/SetBirthdayCommand.cs index a6d02ac..40ef1b6 100644 --- a/InstarBot/Commands/SetBirthdayCommand.cs +++ b/InstarBot/Commands/SetBirthdayCommand.cs @@ -11,17 +11,8 @@ namespace PaxAndromeda.Instar.Commands; // Required to be unsealed for mocking [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class SetBirthdayCommand : BaseCommand +public class SetBirthdayCommand(IInstarDDBService ddbService, IMetricService metricService) : BaseCommand { - private readonly IInstarDDBService _ddbService; - private readonly IMetricService _metricService; - - public SetBirthdayCommand(IInstarDDBService ddbService, IMetricService metricService) - { - _ddbService = ddbService; - _metricService = metricService; - } - [UsedImplicitly] [RequireOwner] [DefaultMemberPermissions(GuildPermission.Administrator)] @@ -69,7 +60,7 @@ await RespondAsync( dtUtc); // TODO: Notify staff? - var ok = await _ddbService.UpdateUserBirthday(new Snowflake(Context.User!.Id), dtUtc); + var ok = await ddbService.UpdateUserBirthday(new Snowflake(Context.User!.Id), dtUtc); if (ok) { @@ -78,7 +69,7 @@ await RespondAsync( dtLocal, dtUtc); await RespondAsync($"Your birthday was set to {dtLocal:D}.", ephemeral: true); - await _metricService.Emit(Metric.BS_BirthdaysSet, 1); + await metricService.Emit(Metric.BS_BirthdaysSet, 1); } else { @@ -96,34 +87,34 @@ await RespondAsync("Your birthday could not be set at this time. Please try aga public async Task HandleTimezoneAutocomplete() { Log.Debug("AUTOCOMPLETE"); - IEnumerable results = new[] - { - new AutocompleteResult("GMT-12 International Date Line West", -12), - new AutocompleteResult("GMT-11 Midway Island, Samoa", -11), - new AutocompleteResult("GMT-10 Hawaii", -10), - new AutocompleteResult("GMT-9 Alaska", -9), - new AutocompleteResult("GMT-8 Pacific Time (US and Canada); Tijuana", -8), - new AutocompleteResult("GMT-7 Mountain Time (US and Canada)", -7), - new AutocompleteResult("GMT-6 Central Time (US and Canada)", -6), - new AutocompleteResult("GMT-5 Eastern Time (US and Canada)", -5), - new AutocompleteResult("GMT-4 Atlantic Time (Canada)", -4), - new AutocompleteResult("GMT-3 Brasilia, Buenos Aires, Georgetown", -3), - new AutocompleteResult("GMT-2 Mid-Atlantic", -2), - new AutocompleteResult("GMT-1 Azores, Cape Verde Islands", -1), - new AutocompleteResult("GMT+0 Greenwich Mean Time: Dublin, Edinburgh, Lisbon, London", 0), - new AutocompleteResult("GMT+1 Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna", 1), - new AutocompleteResult("GMT+2 Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius", 2), - new AutocompleteResult("GMT+3 Moscow, St. Petersburg, Volgograd", 3), - new AutocompleteResult("GMT+4 Abu Dhabi, Muscat", 4), - new AutocompleteResult("GMT+5 Islamabad, Karachi, Tashkent", 5), - new AutocompleteResult("GMT+6 Astana, Dhaka", 6), - new AutocompleteResult("GMT+7 Bangkok, Hanoi, Jakarta", 7), - new AutocompleteResult("GMT+8 Beijing, Chongqing, Hong Kong SAR, Urumqi", 8), - new AutocompleteResult("GMT+9 Seoul, Osaka, Sapporo, Tokyo", 9), - new AutocompleteResult("GMT+10 Canberra, Melbourne, Sydney", 10), - new AutocompleteResult("GMT+11 Magadan, Solomon Islands, New Caledonia", 11), - new AutocompleteResult("GMT+12 Auckland, Wellington", 12) - }; + IEnumerable results = + [ + new("GMT-12 International Date Line West", -12), + new("GMT-11 Midway Island, Samoa", -11), + new("GMT-10 Hawaii", -10), + new("GMT-9 Alaska", -9), + new("GMT-8 Pacific Time (US and Canada); Tijuana", -8), + new("GMT-7 Mountain Time (US and Canada)", -7), + new("GMT-6 Central Time (US and Canada)", -6), + new("GMT-5 Eastern Time (US and Canada)", -5), + new("GMT-4 Atlantic Time (Canada)", -4), + new("GMT-3 Brasilia, Buenos Aires, Georgetown", -3), + new("GMT-2 Mid-Atlantic", -2), + new("GMT-1 Azores, Cape Verde Islands", -1), + new("GMT+0 Greenwich Mean Time: Dublin, Edinburgh, Lisbon, London", 0), + new("GMT+1 Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna", 1), + new("GMT+2 Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius", 2), + new("GMT+3 Moscow, St. Petersburg, Volgograd", 3), + new("GMT+4 Abu Dhabi, Muscat", 4), + new("GMT+5 Islamabad, Karachi, Tashkent", 5), + new("GMT+6 Astana, Dhaka", 6), + new("GMT+7 Bangkok, Hanoi, Jakarta", 7), + new("GMT+8 Beijing, Chongqing, Hong Kong SAR, Urumqi", 8), + new("GMT+9 Seoul, Osaka, Sapporo, Tokyo", 9), + new("GMT+10 Canberra, Melbourne, Sydney", 10), + new("GMT+11 Magadan, Solomon Islands, New Caledonia", 11), + new("GMT+12 Auckland, Wellington", 12) + ]; await (Context.Interaction as SocketAutocompleteInteraction)?.RespondAsync(results)!; } diff --git a/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs b/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs index 988599b..2adac4c 100644 --- a/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs +++ b/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs @@ -5,15 +5,8 @@ namespace PaxAndromeda.Instar.Commands; -public sealed class TriggerAutoMemberSystemCommand : BaseCommand +public sealed class TriggerAutoMemberSystemCommand(AutoMemberSystem ams) : BaseCommand { - private readonly AutoMemberSystem _ams; - - public TriggerAutoMemberSystemCommand(AutoMemberSystem ams) - { - _ams = ams; - } - [UsedImplicitly] [RequireOwner] [DefaultMemberPermissions(GuildPermission.Administrator)] @@ -23,6 +16,6 @@ public async Task RunAMS() await RespondAsync("Auto Member System is running!", ephemeral: true); // Run it asynchronously - await _ams.RunAsync(); + await ams.RunAsync(); } } \ No newline at end of file diff --git a/InstarBot/ConfigurationException.cs b/InstarBot/ConfigurationException.cs index 94b4a8b..0e09111 100644 --- a/InstarBot/ConfigurationException.cs +++ b/InstarBot/ConfigurationException.cs @@ -3,9 +3,4 @@ namespace PaxAndromeda.Instar; [ExcludeFromCodeCoverage] -public sealed class ConfigurationException : Exception -{ - public ConfigurationException(string message) : base(message) - { - } -} \ No newline at end of file +public sealed class ConfigurationException(string message) : Exception(message); \ No newline at end of file diff --git a/InstarBot/Gaius/Caselog.cs b/InstarBot/Gaius/Caselog.cs index c00e107..4fd01be 100644 --- a/InstarBot/Gaius/Caselog.cs +++ b/InstarBot/Gaius/Caselog.cs @@ -6,11 +6,11 @@ namespace PaxAndromeda.Instar.Gaius; [UsedImplicitly] public record Caselog { - [UsedImplicitly] public Snowflake UserID { get; set; } = default!; + [UsedImplicitly] public Snowflake UserID { get; set; } = null!; [UsedImplicitly] public CaselogType Type { get; set; } [UsedImplicitly] public string Time { get; set; } = null!; - [UsedImplicitly] public Snowflake ModID { get; set; } = default!; - [UsedImplicitly] public string Reason { get; set; } = default!; + [UsedImplicitly] public Snowflake ModID { get; set; } = null!; + [UsedImplicitly] public string Reason { get; set; } = null!; [JsonConverter(typeof(UnixMillisecondDateTimeConverter)), UsedImplicitly] public DateTime Date { get; set; } diff --git a/InstarBot/Gaius/Warning.cs b/InstarBot/Gaius/Warning.cs index 241ff93..f927022 100644 --- a/InstarBot/Gaius/Warning.cs +++ b/InstarBot/Gaius/Warning.cs @@ -7,10 +7,10 @@ namespace PaxAndromeda.Instar.Gaius; [UsedImplicitly] public record Warning { - [UsedImplicitly] public Snowflake GuildID { get; set; } = default!; + [UsedImplicitly] public Snowflake GuildID { get; set; } = null!; [UsedImplicitly] public int WarnID { get; set; } - [UsedImplicitly] public Snowflake UserID { get; set; } = default!; - [UsedImplicitly] public string Reason { get; set; } = default!; + [UsedImplicitly] public Snowflake UserID { get; set; } = null!; + [UsedImplicitly] public string Reason { get; set; } = null!; [JsonConverter(typeof(UnixDateTimeConverter)), UsedImplicitly] public DateTime WarnDate { get; set; } @@ -20,5 +20,5 @@ public record Warning [JsonConverter(typeof(UnixDateTimeConverter)), UsedImplicitly] public DateTime? PardonDate { get; set; } - [UsedImplicitly] public Snowflake ModID { get; set; } = default!; + [UsedImplicitly] public Snowflake ModID { get; set; } = null!; } \ No newline at end of file diff --git a/InstarBot/InstarBot.csproj b/InstarBot/InstarBot.csproj index b313ff0..761299a 100644 --- a/InstarBot/InstarBot.csproj +++ b/InstarBot/InstarBot.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net9.0 enable enable PaxAndromeda.Instar @@ -14,25 +14,25 @@ - - - - - + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/InstarBot/MessageProperties.cs b/InstarBot/MessageProperties.cs index 1b16234..b56a91f 100644 --- a/InstarBot/MessageProperties.cs +++ b/InstarBot/MessageProperties.cs @@ -5,16 +5,9 @@ namespace PaxAndromeda.Instar; [StructLayout(LayoutKind.Sequential)] [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] -public readonly struct MessageProperties +public readonly struct MessageProperties(ulong userId, ulong channelId, ulong guildId) { - public readonly ulong UserID; - public readonly ulong ChannelID; - public readonly ulong GuildID; - - public MessageProperties(ulong userId, ulong channelId, ulong guildId) - { - UserID = userId; - ChannelID = channelId; - GuildID = guildId; - } + public readonly ulong UserID = userId; + public readonly ulong ChannelID = channelId; + public readonly ulong GuildID = guildId; } \ No newline at end of file diff --git a/InstarBot/Metrics/MetricDimensionAttribute.cs b/InstarBot/Metrics/MetricDimensionAttribute.cs index bcc1e80..7de31d8 100644 --- a/InstarBot/Metrics/MetricDimensionAttribute.cs +++ b/InstarBot/Metrics/MetricDimensionAttribute.cs @@ -1,14 +1,8 @@ namespace PaxAndromeda.Instar.Metrics; [AttributeUsage(AttributeTargets.Field)] -public sealed class MetricDimensionAttribute : Attribute +public sealed class MetricDimensionAttribute(string name, string value) : Attribute { - public string Name { get; } - public string Value { get; } - - public MetricDimensionAttribute(string name, string value) - { - Name = name; - Value = value; - } + public string Name { get; } = name; + public string Value { get; } = value; } \ No newline at end of file diff --git a/InstarBot/Metrics/MetricNameAttribute.cs b/InstarBot/Metrics/MetricNameAttribute.cs index 07a060b..4a4414b 100644 --- a/InstarBot/Metrics/MetricNameAttribute.cs +++ b/InstarBot/Metrics/MetricNameAttribute.cs @@ -1,12 +1,7 @@ namespace PaxAndromeda.Instar.Metrics; [AttributeUsage(AttributeTargets.Field)] -public sealed class MetricNameAttribute : Attribute +public sealed class MetricNameAttribute(string name) : Attribute { - public string Name { get; } - - public MetricNameAttribute(string name) - { - Name = name; - } + public string Name { get; } = name; } \ No newline at end of file diff --git a/InstarBot/PageTarget.cs b/InstarBot/PageTarget.cs index 9a81eea..4b4d523 100644 --- a/InstarBot/PageTarget.cs +++ b/InstarBot/PageTarget.cs @@ -1,5 +1,4 @@ -using Discord.Interactions; -using JetBrains.Annotations; +using JetBrains.Annotations; namespace PaxAndromeda.Instar; diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index 5d8139a..0648c45 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -49,8 +49,15 @@ public static async Task Main(string[] args) private static async void StopSystem(object? sender, ConsoleCancelEventArgs e) { - await _services.GetRequiredService().Stop(); - _cts.Cancel(); + try + { + await _services.GetRequiredService().Stop(); + await _cts.CancelAsync(); + } + catch (Exception err) + { + Log.Fatal(err, "FATAL: Unhandled exception caught during shutdown"); + } } private static async Task RunAsync(IConfiguration config) diff --git a/InstarBot/Services/AWSDynamicConfigService.cs b/InstarBot/Services/AWSDynamicConfigService.cs index 9b8bdd7..c994d8f 100644 --- a/InstarBot/Services/AWSDynamicConfigService.cs +++ b/InstarBot/Services/AWSDynamicConfigService.cs @@ -111,7 +111,7 @@ private async Task Poll(bool bypass) }); _nextToken = result.NextPollConfigurationToken; - _nextPollTime = DateTime.UtcNow + TimeSpan.FromSeconds(result.NextPollIntervalInSeconds); + _nextPollTime = DateTime.UtcNow + TimeSpan.FromSeconds((double)(result.NextPollIntervalInSeconds ?? 60)); // Per the documentation, if VersionLabel is empty, then the client // has the most up-to-date configuration already stored. We can stop diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index 0d49e50..63d320a 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -150,10 +150,17 @@ private void StartTimer() private async void TimerElapsed(object? sender, ElapsedEventArgs e) { - // Ensure the timer's interval is exactly 1 hour - _timer.Interval = 60 * 60 * 1000; + try + { + // Ensure the timer's interval is exactly 1 hour + _timer.Interval = 60 * 60 * 1000; - await RunAsync(); + await RunAsync(); + } + catch + { + // ignore + } } public async Task RunAsync() @@ -163,7 +170,7 @@ public async Task RunAsync() await _metricService.Emit(Metric.AMS_Runs, 1); var cfg = await _dynamicConfig.GetConfig(); - // Caution: This is an extremely long running method! + // Caution: This is an extremely long-running method! Log.Information("Beginning auto member routine"); if (cfg.AutoMemberConfig.EnableGaiusCheck) @@ -195,7 +202,7 @@ public async Task RunAsync() foreach (var user in newMembers) { - // User has all of the qualifications, let's update their role + // User has all the qualifications, let's update their role try { await GrantMembership(cfg, user); @@ -226,7 +233,7 @@ private async Task WasUserGrantedMembershipBefore(Snowflake snowflake) if (grantedMembership is null) return false; - // Cache for 6 hour sliding window. If accessed, time is reset. + // Cache for 6-hour sliding window. If accessed, time is reset. _ddbCache.Add(snowflake.ID.ToString(), grantedMembership.Value, new CacheItemPolicy { SlidingExpiration = TimeSpan.FromHours(6) @@ -303,9 +310,7 @@ private Dictionary GetMessagesSent() foreach (var cacheEntry in _messageCache) { - if (!map.ContainsKey(cacheEntry.Value.UserID)) - map.Add(cacheEntry.Value.UserID, 1); - else + if (!map.TryAdd(cacheEntry.Value.UserID, 1)) map[cacheEntry.Value.UserID]++; } diff --git a/InstarBot/Services/CloudwatchMetricService.cs b/InstarBot/Services/CloudwatchMetricService.cs index 24f8fc6..724d61a 100644 --- a/InstarBot/Services/CloudwatchMetricService.cs +++ b/InstarBot/Services/CloudwatchMetricService.cs @@ -47,10 +47,7 @@ public async Task Emit(Metric metric, double value) var response = await _client.PutMetricDataAsync(new PutMetricDataRequest { Namespace = _metricNamespace, - MetricData = new List - { - datum - } + MetricData = [datum] }); return response.HttpStatusCode == HttpStatusCode.OK; diff --git a/InstarBot/Services/DiscordService.cs b/InstarBot/Services/DiscordService.cs index 49d3d59..8284b81 100644 --- a/InstarBot/Services/DiscordService.cs +++ b/InstarBot/Services/DiscordService.cs @@ -85,14 +85,14 @@ private async Task HandleMessageCommand(SocketMessageCommand arg) { Log.Information("Message command: {CommandName}", arg.CommandName); - if (!_contextCommands.ContainsKey(arg.CommandName)) + if (!_contextCommands.TryGetValue(arg.CommandName, out var command)) { Log.Warning("Received message command interaction for unknown command by name {CommandName}", arg.CommandName); return; } - await _contextCommands[arg.CommandName].HandleCommand(new MessageCommandInteractionWrapper(arg)); + await command.HandleCommand(new MessageCommandInteractionWrapper(arg)); } private async Task HandleInteraction(SocketInteraction arg) @@ -187,14 +187,14 @@ public async Task> GetAllUsers() try { var guild = _socketClient.GetGuild(_guild); - await _socketClient.DownloadUsersAsync(new[] { guild }); + await _socketClient.DownloadUsersAsync([guild]); return guild.Users; } catch (Exception ex) { Log.Error(ex, "Failed to download users for guild {GuildID}", _guild); - return Array.Empty(); + return []; } } diff --git a/InstarBot/Services/GaiusAPIService.cs b/InstarBot/Services/GaiusAPIService.cs index 2480d56..162e925 100644 --- a/InstarBot/Services/GaiusAPIService.cs +++ b/InstarBot/Services/GaiusAPIService.cs @@ -4,25 +4,18 @@ namespace PaxAndromeda.Instar.Services; -public sealed class GaiusAPIService : IGaiusAPIService +public sealed class GaiusAPIService(IDynamicConfigService config) : IGaiusAPIService { // Used in release mode // ReSharper disable once NotAccessedField.Local - private readonly IDynamicConfigService _config; private const string BaseURL = "https://api.gaiusbot.me"; private const string WarningsBaseURL = BaseURL + "/warnings"; private const string CaselogsBaseURL = BaseURL + "/caselogs"; - private readonly HttpClient _client; + private readonly HttpClient _client = new(); private string _apiKey = null!; private bool _initialized; private readonly SemaphoreSlim _semaphore = new(1, 1); - - public GaiusAPIService(IDynamicConfigService config) - { - _config = config; - _client = new HttpClient(); - } private async Task Initialize() { @@ -35,7 +28,7 @@ private async Task Initialize() if (_initialized) return; - _apiKey = await _config.GetParameter("GaiusKey") ?? + _apiKey = await config.GetParameter("GaiusKey") ?? throw new ConfigurationException("Could not acquire Gaius API key"); await VerifyKey(); _initialized = true; @@ -48,7 +41,7 @@ private async Task Initialize() private async Task VerifyKey() { - var cfg = await _config.GetConfig(); + var cfg = await config.GetConfig(); var targetGuild = cfg.TargetGuild; var keyData = Encoding.UTF8.GetString(Convert.FromBase64String(_apiKey)); @@ -72,7 +65,7 @@ public async Task> GetAllWarnings() var response = await Get($"{WarningsBaseURL}/all"); var result = JsonConvert.DeserializeObject(response); - return result ?? Array.Empty(); + return result ?? []; } public async Task> GetAllCaselogs() @@ -90,7 +83,7 @@ public async Task> GetWarningsAfter(DateTime dt) var response = await Get($"{WarningsBaseURL}/after/{dt:O}"); var result = JsonConvert.DeserializeObject(response); - return result ?? Array.Empty(); + return result ?? []; } public async Task> GetCaselogsAfter(DateTime dt) @@ -119,9 +112,11 @@ public async Task> GetCaselogsAfter(DateTime dt) private static IEnumerable ParseCaselogs(string response) { - // Remove the "totalCases" portion if it exists - if (response.Contains("totalCases", StringComparison.OrdinalIgnoreCase)) - response = response[..(response.LastIndexOf("\"totalCases\"", StringComparison.OrdinalIgnoreCase)-1)] + "}"; + // Remove any instances of "totalCases" + while (response.Contains("\"totalcases\":", StringComparison.OrdinalIgnoreCase)) + { + var start = response.IndexOf("\"totalcases\":", StringComparison.OrdinalIgnoreCase); + var end = response.IndexOfAny([',', '}'], start); if (response.Length <= 2) yield break; diff --git a/InstarBot/Services/InstarDDBService.cs b/InstarBot/Services/InstarDDBService.cs index 6713ff7..4dacfdd 100644 --- a/InstarBot/Services/InstarDDBService.cs +++ b/InstarBot/Services/InstarDDBService.cs @@ -73,7 +73,7 @@ public async Task UpdateUserMembership(Snowflake snowflake, bool membershi private async Task UpdateUserData(Snowflake snowflake, DataType dataType, T data) where T : DynamoDBEntry { - var table = Table.LoadTable(_client, TableName); + var table = new TableBuilder(_client, TableName).Build(); var updateData = new Document(new Dictionary { @@ -92,7 +92,7 @@ private async Task UpdateUserData(Snowflake snowflake, DataType dataTyp private async Task GetUserData(Snowflake snowflake, DataType dataType) { - var table = Table.LoadTable(_client, TableName); + var table = new TableBuilder(_client, TableName).Build(); var scan = table.Query(new Primitive(snowflake.ID.ToString()), new QueryFilter()); var results = await scan.GetRemainingAsync(); diff --git a/InstarBot/Services/TeamService.cs b/InstarBot/Services/TeamService.cs index 6fecc27..e129e0b 100644 --- a/InstarBot/Services/TeamService.cs +++ b/InstarBot/Services/TeamService.cs @@ -7,46 +7,39 @@ namespace PaxAndromeda.Instar.Services; [SuppressMessage("ReSharper", "UnusedMember.Global")] [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] -public sealed class TeamService +public sealed class TeamService(IDynamicConfigService dynamicConfig) { - private readonly IDynamicConfigService _dynamicConfig; - - public TeamService(IDynamicConfigService dynamicConfig) - { - _dynamicConfig = dynamicConfig; - } - public async Task Exists(string teamRef) { - var cfg = await _dynamicConfig.GetConfig(); + var cfg = await dynamicConfig.GetConfig(); return cfg.Teams.Any(n => n.InternalID.Equals(teamRef, StringComparison.Ordinal)); } public async Task Exists(Snowflake snowflake) { - var cfg = await _dynamicConfig.GetConfig(); + var cfg = await dynamicConfig.GetConfig(); return cfg.Teams.Any(n => n.ID.ID == snowflake.ID); } public async Task Get(string teamRef) { - var cfg = await _dynamicConfig.GetConfig(); + var cfg = await dynamicConfig.GetConfig(); return cfg.Teams.First(n => n.InternalID.Equals(teamRef, StringComparison.Ordinal)); } public async Task Get(Snowflake snowflake) { - var cfg = await _dynamicConfig.GetConfig(); + var cfg = await dynamicConfig.GetConfig(); return cfg.Teams.First(n => n.ID.ID == snowflake.ID); } public async IAsyncEnumerable GetTeams(PageTarget pageTarget) { - var cfg = await _dynamicConfig.GetConfig(); + var cfg = await dynamicConfig.GetConfig(); var teamRefs = pageTarget.GetAttributesOfType()?.Select(n => n.InternalID) ?? new List(); diff --git a/InstarBot/Snowflake.cs b/InstarBot/Snowflake.cs index 43de93b..d5be95a 100644 --- a/InstarBot/Snowflake.cs +++ b/InstarBot/Snowflake.cs @@ -27,7 +27,7 @@ namespace PaxAndromeda.Instar; /// [TypeConverter(typeof(SnowflakeConverter))] [JsonConverter(typeof(JSnowflakeConverter))] -public sealed class Snowflake : IEquatable +public sealed record Snowflake { private static int _increment; diff --git a/InstarBot/SnowflakeTypeAttribute.cs b/InstarBot/SnowflakeTypeAttribute.cs index cc289b3..cab5c00 100644 --- a/InstarBot/SnowflakeTypeAttribute.cs +++ b/InstarBot/SnowflakeTypeAttribute.cs @@ -1,12 +1,7 @@ namespace PaxAndromeda.Instar; [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] -public sealed class SnowflakeTypeAttribute : Attribute +public sealed class SnowflakeTypeAttribute(SnowflakeType type) : Attribute { - public SnowflakeType Type { get; } - - public SnowflakeTypeAttribute(SnowflakeType type) - { - Type = type; - } + public SnowflakeType Type { get; } = type; } \ No newline at end of file diff --git a/InstarBot/TeamRefAttribute.cs b/InstarBot/TeamRefAttribute.cs index 33f3c84..f98c670 100644 --- a/InstarBot/TeamRefAttribute.cs +++ b/InstarBot/TeamRefAttribute.cs @@ -5,12 +5,7 @@ /// regardless of the team's snowflake. /// [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] -public sealed class TeamRefAttribute : Attribute +public sealed class TeamRefAttribute(string teamInternalId) : Attribute { - public TeamRefAttribute(string teamInternalId) - { - InternalID = teamInternalId; - } - - public string InternalID { get; } + public string InternalID { get; } = teamInternalId; } \ No newline at end of file diff --git a/InstarBot/Wrappers/MessageCommandInteractionWrapper.cs b/InstarBot/Wrappers/MessageCommandInteractionWrapper.cs index 4e563d3..f787be5 100644 --- a/InstarBot/Wrappers/MessageCommandInteractionWrapper.cs +++ b/InstarBot/Wrappers/MessageCommandInteractionWrapper.cs @@ -9,22 +9,15 @@ namespace PaxAndromeda.Instar.Wrappers; /// [ExcludeFromCodeCoverage(Justification = "Wrapper class")] [SuppressMessage("ReSharper", "ClassWithVirtualMembersNeverInherited.Global")] -public class MessageCommandInteractionWrapper : IInstarMessageCommandInteraction +public class MessageCommandInteractionWrapper(IMessageCommandInteraction interaction) : IInstarMessageCommandInteraction { - private readonly IMessageCommandInteraction _interaction; - - public MessageCommandInteractionWrapper(IMessageCommandInteraction interaction) - { - _interaction = interaction; - } - - public virtual ulong Id => _interaction.Id; - public virtual IUser User => _interaction.User; - public virtual IMessageCommandInteractionData Data => _interaction.Data; + public virtual ulong Id => interaction.Id; + public virtual IUser User => interaction.User; + public virtual IMessageCommandInteractionData Data => interaction.Data; public virtual Task RespondWithModalAsync(string customId, RequestOptions options = null!, Action modifyModal = null!) where T : class, IModal { - return _interaction.RespondWithModalAsync(customId, options, modifyModal); + return interaction.RespondWithModalAsync(customId, options, modifyModal); } } \ No newline at end of file diff --git a/InstarBot/Wrappers/SocketGuildWrapper.cs b/InstarBot/Wrappers/SocketGuildWrapper.cs index 13157fd..1dd7207 100644 --- a/InstarBot/Wrappers/SocketGuildWrapper.cs +++ b/InstarBot/Wrappers/SocketGuildWrapper.cs @@ -9,14 +9,9 @@ namespace PaxAndromeda.Instar.Wrappers; /// [ExcludeFromCodeCoverage(Justification = "Wrapper class")] [SuppressMessage("ReSharper", "ClassWithVirtualMembersNeverInherited.Global")] -public class SocketGuildWrapper : IInstarGuild +public class SocketGuildWrapper(SocketGuild guild) : IInstarGuild { - private readonly SocketGuild _guild; - - public SocketGuildWrapper(SocketGuild guild) - { - _guild = guild ?? throw new ArgumentNullException(nameof(guild)); - } + private readonly SocketGuild _guild = guild ?? throw new ArgumentNullException(nameof(guild)); public virtual ulong Id => _guild.Id; public IEnumerable TextChannels => _guild.TextChannels; From 33defb83d86ba29bd14c8111da7ea4d90095b367 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Wed, 2 Jul 2025 12:35:24 -0700 Subject: [PATCH 02/53] Refactored InstarBot.Tests.Integration to move away from SpecFlow - Removed SpecFlow feature files, codehooks, step definitions and other metadata. - Added new xUnit based integration tests. - Added some minor validation updates to SetBirthdayCommand.cs - Refactored some code with new C# language features --- InstarBot.Tests.Common/TestContext.cs | 24 +- .../Features/AutoMemberSystem.feature | 158 ------ .../Features/PageCommand.feature | 53 -- .../Features/PingCommand.feature | 9 - .../Features/ReportUserCommand.feature | 25 - .../Features/SetBirthdayCommand.feature | 57 -- InstarBot.Tests.Integration/Hooks/Hook.cs | 23 - .../InstarBot.Tests.Integration.csproj | 8 +- .../Interactions/AutoMemberSystemTests.cs | 496 ++++++++++++++++++ .../Interactions/PageCommandTests.cs | 225 ++++++++ .../Interactions/PingCommandTests.cs | 26 + .../Interactions/ReportUserTests.cs | 180 +++++++ .../Interactions/SetBirthdayCommandTests.cs | 111 ++++ .../Steps/AutoMemberSystemStepDefinitions.cs | 227 -------- .../Steps/BirthdayCommandStepDefinitions.cs | 72 --- .../Steps/PageCommandStepDefinitions.cs | 141 ----- .../Steps/PingCommandStepDefinitions.cs | 16 - .../Steps/ReportUserCommandStepDefinitions.cs | 129 ----- InstarBot/Commands/SetBirthdayCommand.cs | 15 +- 19 files changed, 1062 insertions(+), 933 deletions(-) delete mode 100644 InstarBot.Tests.Integration/Features/AutoMemberSystem.feature delete mode 100644 InstarBot.Tests.Integration/Features/PageCommand.feature delete mode 100644 InstarBot.Tests.Integration/Features/PingCommand.feature delete mode 100644 InstarBot.Tests.Integration/Features/ReportUserCommand.feature delete mode 100644 InstarBot.Tests.Integration/Features/SetBirthdayCommand.feature delete mode 100644 InstarBot.Tests.Integration/Hooks/Hook.cs create mode 100644 InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs create mode 100644 InstarBot.Tests.Integration/Interactions/PageCommandTests.cs create mode 100644 InstarBot.Tests.Integration/Interactions/PingCommandTests.cs create mode 100644 InstarBot.Tests.Integration/Interactions/ReportUserTests.cs create mode 100644 InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs delete mode 100644 InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs delete mode 100644 InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs delete mode 100644 InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs delete mode 100644 InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs delete mode 100644 InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs diff --git a/InstarBot.Tests.Common/TestContext.cs b/InstarBot.Tests.Common/TestContext.cs index 387c2dc..7babfc7 100644 --- a/InstarBot.Tests.Common/TestContext.cs +++ b/InstarBot.Tests.Common/TestContext.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using Discord; using InstarBot.Tests.Models; using Moq; @@ -7,7 +6,6 @@ namespace InstarBot.Tests; -[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] public sealed class TestContext { public ulong UserID { get; init; } = 1420070400100; @@ -22,28 +20,26 @@ public sealed class TestContext public List GuildUsers { get; init; } = []; - public Dictionary Channels { get; init; } = new(); - public Dictionary Roles { get; init; } = new(); + public Dictionary Channels { get; init; } = []; + public Dictionary Roles { get; init; } = []; - public Dictionary> Warnings { get; init; } = new(); - public Dictionary> Caselogs { get; init; } = new(); + public Dictionary> Warnings { get; init; } = []; + public Dictionary> Caselogs { get; init; } = []; public bool InhibitGaius { get; set; } public void AddWarning(Snowflake userId, Warning warning) { - if (!Warnings.ContainsKey(userId)) - Warnings.Add(userId, [warning]); - else - Warnings[userId].Add(warning); + if (!Warnings.TryGetValue(userId, out var list)) + Warnings[userId] = list = []; + list.Add(warning); } public void AddCaselog(Snowflake userId, Caselog caselog) { - if (!Caselogs.ContainsKey(userId)) - Caselogs.Add(userId, [caselog]); - else - Caselogs[userId].Add(caselog); + if (!Caselogs.TryGetValue(userId, out var list)) + Caselogs[userId] = list = []; + list.Add(caselog); } public void AddChannel(Snowflake channelId) diff --git a/InstarBot.Tests.Integration/Features/AutoMemberSystem.feature b/InstarBot.Tests.Integration/Features/AutoMemberSystem.feature deleted file mode 100644 index 11c0f80..0000000 --- a/InstarBot.Tests.Integration/Features/AutoMemberSystem.feature +++ /dev/null @@ -1,158 +0,0 @@ -@systems -Feature: Auto Member System - - The Auto Member System evaluates all new members on the server - and determines their eligibility for membership through a series - of checks. - - Background: - Given the roles as follows: - | Role ID | Role Name | - | 796052052433698817 | New Member | - | 793611808372031499 | Member | - | 796085775199502357 | Transfemme | - | 796148869855576064 | 21+ | - | 796578609535647765 | She/Her | - | 966434762032054282 | AMH | - - Rule: Eligible users should be granted membership. - Scenario: A user eligible for membership should be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme, 21+ and She/Her - * Posted an introduction - * Posted 100 messages in the past day - * Not been punished - * Joined the server for the first time - * Not been granted membership before - When the Auto Member System processes - Then the user should be granted membership - - Rule: Eligible users should not be granted membership if their membership is withheld. - Scenario: A user eligible for membership should not be granted membership if their membership is withheld. - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme, 21+, She/Her and AMH - * Posted an introduction - * Posted 100 messages in the past day - * Not been punished - * Joined the server for the first time - * Not been granted membership before - When the Auto Member System processes - Then the user should not be granted membership - - Rule: New or inactive users should not be granted membership. - Scenario: A user who joined the server less than the minimum time should not be granted membership - Given a user that has: - * Joined 12 hours ago - * The roles New Member, Transfemme, 21+ and She/Her - * Posted an introduction - * Posted 100 messages in the past day - * Not been punished - * Joined the server for the first time - * Not been granted membership before - When the Auto Member System processes - Then the user should not be granted membership - - Scenario: An inactive user should not be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme, 21+ and She/Her - * Posted an introduction - * Posted 10 messages in the past day - * Not been punished - When the Auto Member System processes - Then the user should not be granted membership - - Rule: Auto Member System should not affect Members. - Scenario: A user who is already a member should not be affected by the auto member system - Given a user that has: - * Joined 36 hours ago - * The roles Member, Transfemme, 21+ and She/Her - * Posted an introduction - * Posted 100 messages in the past day - * Not been punished - * Joined the server for the first time - * Not been granted membership before - When the Auto Member System processes - Then the user should remain unchanged - - Rule: Users should have all minimum requirements for membership - Scenario: A user that did not post an introduction should not be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme, 21+ and She/Her - * Did not post an introduction - * Posted 100 messages in the past day - * Not been punished - * Joined the server for the first time - * Not been granted membership before - When the Auto Member System processes - Then the user should not be granted membership - - Scenario: A user without an age role should not be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme and She/Her - * Posted an introduction - * Posted 100 messages in the past day - * Not been punished - When the Auto Member System processes - Then the user should not be granted membership - - Scenario: A user without a gender role should not be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, 21+ and She/Her - * Posted an introduction - * Posted 100 messages in the past day - * Not been punished - When the Auto Member System processes - Then the user should not be granted membership - - Scenario: A user without a pronoun role should not be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme, and 21+ - * Posted an introduction - * Posted 100 messages in the past day - * Not been punished - When the Auto Member System processes - Then the user should not be granted membership - - Rule: Gaius should be checked for warnings and caselogs - Scenario: A user with a warning should not be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme, 21+ and She/Her - * Posted an introduction - * Posted 100 messages in the past day - * Been issued a warning - * Joined the server for the first time - * Not been granted membership before - When the Auto Member System processes - Then the user should not be granted membership - - Scenario: A user with a caselog should not be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme, 21+ and She/Her - * Posted an introduction - * Posted 100 messages in the past day - * Been issued a mute - When the Auto Member System processes - Then the user should not be granted membership - - Rule: Users who have been granted membership and left should be granted membership upon rejoining - - Scenario: A user who has had membership before should be automatically granted membership - Given a user that has: - * Joined 1 hours ago - * Been granted membership before - * First joined 240 hours ago - * The roles New Member, Transfemme, 21+ and She/Her - * Posted an introduction - * Posted 2 messages in the past day - * Not been punished - When the user joins the server - Then the user should be granted membership \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Features/PageCommand.feature b/InstarBot.Tests.Integration/Features/PageCommand.feature deleted file mode 100644 index 4b609de..0000000 --- a/InstarBot.Tests.Integration/Features/PageCommand.feature +++ /dev/null @@ -1,53 +0,0 @@ -@interactions -@staff -Feature: Page Command - - The Page command is a staff-only utility to page specific - staff teams without needing to allow pinging of the team's - role to everybody. - - The command also permits several contextual parameters - that allow responding staff members to obtain context on - a situation quickly and efficiently. - - Scenario: User should be able to page when authorized - Given the user is in team Owner - And the user is paging Moderator - When the user calls the Page command - Then Instar should emit a valid Page embed - - Scenario: User should be able to page a team's teamleader - Given the user is in team Helper - And the user is paging the Owner teamleader - When the user calls the Page command - Then Instar should emit a valid teamleader Page embed - - Scenario: Any staff member should be able to use the Test page - Given the user is in team Helper - And the user is paging Test - When the user calls the Page command - Then Instar should emit a valid Page embed - - Scenario: Owner should be able to page all - Given the user is in team Owner - And the user is paging All - When the user calls the Page command - Then Instar should emit a valid All Page embed - - Scenario: Fail page if paging all teamleader - Given the user is in team Owner - And the user is paging the All teamleader - When the user calls the Page command - Then Instar should emit an ephemeral message stating "Failed to send page. The 'All' team does not have a teamleader. If intended to page the owner, please select the Owner as the team." - - Scenario: Unauthorized user should receive an error message - Given the user is not a staff member - And the user is paging Moderator - When the user calls the Page command - Then Instar should emit an ephemeral message stating "You are not authorized to use this command." - - Scenario: Helper should not be able to page all - Given the user is in team Helper - And the user is paging All - When the user calls the Page command - Then Instar should emit an ephemeral message stating "You are not authorized to send a page to the entire staff team." \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Features/PingCommand.feature b/InstarBot.Tests.Integration/Features/PingCommand.feature deleted file mode 100644 index 4bf8b4e..0000000 --- a/InstarBot.Tests.Integration/Features/PingCommand.feature +++ /dev/null @@ -1,9 +0,0 @@ -@interactions -Feature: Ping Command - - The Ping command is a simple mechanism to test - the responsiveness of the Instar bot. - - Scenario: User should be able to issue the Ping command. - When the user calls the Ping command - Then Instar should emit an ephemeral message stating "Pong!" \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Features/ReportUserCommand.feature b/InstarBot.Tests.Integration/Features/ReportUserCommand.feature deleted file mode 100644 index 62801ec..0000000 --- a/InstarBot.Tests.Integration/Features/ReportUserCommand.feature +++ /dev/null @@ -1,25 +0,0 @@ -@interactions -Feature: Report User Command - - The Report User interaction allows users to report - users and messages quietly without alerting the - reported user. - - Scenario: User should be able to report a message normally - When the user 1024 reports a message with the following properties - | Key | Value | - | Content | "This is a test message" | - | Sender | 128 | - | Channel | 256 | - And completes the report modal with reason "This is a test report" - Then Instar should emit an ephemeral message stating "Your report has been sent." - And Instar should emit a message report embed - - Scenario: Report user function times out if cache expires - When the user 1024 reports a message with the following properties - | Key | Value | - | Content | "This is a test message" | - | Sender | 128 | - | Channel | 256 | - And does not complete the modal within 5 minutes - Then Instar should emit an ephemeral message stating "Report expired. Please try again." \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Features/SetBirthdayCommand.feature b/InstarBot.Tests.Integration/Features/SetBirthdayCommand.feature deleted file mode 100644 index 3fb8ebe..0000000 --- a/InstarBot.Tests.Integration/Features/SetBirthdayCommand.feature +++ /dev/null @@ -1,57 +0,0 @@ -@interactions -Feature: Set Birthday Command - - The Set Birthday command allows users to set - their own birthdays, which is used within the - Birthday system. - - Scenario: User should be able to set a valid birthday - Given the user provides the following parameters - | Key | Value | - | Year | 1992 | - | Month | 7 | - | Day | 21 | - When the user calls the Set Birthday command - Then Instar should emit an ephemeral message stating "Your birthday was set to Tuesday, July 21, 1992." - And DynamoDB should have the user's Birthday set to 1992-07-21T00:00:00-00:00 - - # Note: Update this in 13 years - - Scenario: Underage user should be able to set a valid birthday - Given the user provides the following parameters - | Key | Value | - | Year | 2022 | - | Month | 7 | - | Day | 21 | - When the user calls the Set Birthday command - Then Instar should emit an ephemeral message stating "Your birthday was set to Thursday, July 21, 2022." - And DynamoDB should have the user's Birthday set to 2022-07-21T00:00:00-00:00 - - Scenario: User should be able to set a valid birthday with time zones - Given the user provides the following parameters - | Key | Value | - | Year | 1992 | - | Month | 7 | - | Day | 21 | - | Timezone | -8 | - When the user calls the Set Birthday command - Then Instar should emit an ephemeral message stating "Your birthday was set to Tuesday, July 21, 1992." - And DynamoDB should have the user's Birthday set to 1992-07-21T00:00:00-08:00 - - Scenario: Attempting to set an illegal day number should emit an error message. - Given the user provides the following parameters - | Key | Value | - | Year | 1992 | - | Month | 2 | - | Day | 31 | - When the user calls the Set Birthday command - Then Instar should emit an ephemeral message stating "There are only 29 days in February 1992. Your birthday was not set." - - Scenario: Attempting to set a birthday in the future should emit an error message - Given the user provides the following parameters - | Key | Value | - | Year | 9992 | - | Month | 2 | - | Day | 21 | - When the user calls the Set Birthday command - Then Instar should emit an ephemeral message stating "You are not a time traveler. Your birthday was not set." \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Hooks/Hook.cs b/InstarBot.Tests.Integration/Hooks/Hook.cs deleted file mode 100644 index 7cbb8d2..0000000 --- a/InstarBot.Tests.Integration/Hooks/Hook.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Xunit; - -namespace InstarBot.Tests.Integration.Hooks; - -[Binding] -public class InstarHooks(ScenarioContext scenarioContext) -{ - [Then(@"Instar should emit a message stating ""(.*)""")] - public void ThenInstarShouldEmitAMessageStating(string message) - { - Assert.True(scenarioContext.ContainsKey("Command")); - var cmdObject = scenarioContext.Get("Command"); - TestUtilities.VerifyMessage(cmdObject, message); - } - - [Then(@"Instar should emit an ephemeral message stating ""(.*)""")] - public void ThenInstarShouldEmitAnEphemeralMessageStating(string message) - { - Assert.True(scenarioContext.ContainsKey("Command")); - var cmdObject = scenarioContext.Get("Command"); - TestUtilities.VerifyMessage(cmdObject, message, true); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj index 4f762d8..157a6ad 100644 --- a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj +++ b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -6,16 +6,10 @@ enable - - - - - - all diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs new file mode 100644 index 0000000..72d6663 --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs @@ -0,0 +1,496 @@ +using Discord; +using FluentAssertions; +using InstarBot.Tests.Models; +using InstarBot.Tests.Services; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.Gaius; +using PaxAndromeda.Instar.Services; +using Xunit; + +namespace InstarBot.Tests.Integration.Interactions; + +public class AutoMemberSystemTests +{ + private static readonly Snowflake NewMember = new(796052052433698817); + private static readonly Snowflake Member = new(793611808372031499); + private static readonly Snowflake Transfemme = new(796085775199502357); + private static readonly Snowflake TwentyOnePlus = new(796148869855576064); + private static readonly Snowflake SheHer = new(796578609535647765); + private static readonly Snowflake AutoMemberHold = new(966434762032054282); + + private static async Task SetupTest(AutoMemberSystemContext scenarioContext) + { + var testContext = scenarioContext.TestContext; + + var discordService = TestUtilities.SetupDiscordService(testContext); + var gaiusApiService = TestUtilities.SetupGaiusAPIService(testContext); + var config = TestUtilities.GetDynamicConfiguration(); + + scenarioContext.DiscordService = discordService; + var userId = scenarioContext.UserID; + var relativeJoinTime = scenarioContext.HoursSinceJoined; + var roles = scenarioContext.Roles; + var postedIntro = scenarioContext.PostedIntroduction; + var messagesLast24Hours = scenarioContext.MessagesLast24Hours; + var firstSeenTime = scenarioContext.FirstJoinTime; + var grantedMembershipBefore = scenarioContext.GrantedMembershipBefore; + + var amsConfig = scenarioContext.Config.AutoMemberConfig; + + var ddbService = new MockInstarDDBService(); + if (firstSeenTime > 0) + await ddbService.UpdateUserJoinDate(userId, DateTime.UtcNow - TimeSpan.FromHours(firstSeenTime)); + if (grantedMembershipBefore) + await ddbService.UpdateUserMembership(userId, grantedMembershipBefore); + + testContext.AddRoles(roles); + + var user = new TestGuildUser + { + Id = userId, + JoinedAt = DateTimeOffset.Now - TimeSpan.FromHours(relativeJoinTime), + RoleIds = roles.Select(n => n.ID).ToList().AsReadOnly() + }; + + testContext.GuildUsers.Add(user); + + + var genericChannel = Snowflake.Generate(); + testContext.AddChannel(amsConfig.IntroductionChannel); + + testContext.AddChannel(genericChannel); + if (postedIntro) + testContext.GetChannel(amsConfig.IntroductionChannel).AddMessage(user, "Some text"); + + for (var i = 0; i < messagesLast24Hours; i++) + testContext.GetChannel(genericChannel).AddMessage(user, "Some text"); + + + var ams = new AutoMemberSystem(config, discordService, gaiusApiService, ddbService, new MockMetricService()); + + scenarioContext.User = user; + + return ams; + } + + + [Fact(DisplayName = "Eligible users should be granted membership")] + public static async Task AutoMemberSystem_EligibleUser_ShouldBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertMember(); + } + + + [Fact(DisplayName = "Eligible users should not be granted membership if their membership is withheld.")] + public static async Task AutoMemberSystem_EligibleUserWithAMH_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer, AutoMemberHold) + .HasPostedIntroduction() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + + [Fact(DisplayName = "New users should not be granted membership.")] + public static async Task AutoMemberSystem_NewUser_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(12)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "Inactive users should not be granted membership.")] + public static async Task AutoMemberSystem_InactiveUser_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .WithMessages(10) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "Auto Member System should not affect Members.")] + public static async Task AutoMemberSystem_Member_ShouldNotBeChanged() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(Member, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertUserUnchanged(); + } + + [Fact(DisplayName = "A user that did not post an introduction should not be granted membership")] + public static async Task AutoMemberSystem_NoIntroduction_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "A user without an age role should not be granted membership")] + public static async Task AutoMemberSystem_NoAgeRole_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, SheHer) + .HasPostedIntroduction() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "A user without a gender role should not be granted membership")] + public static async Task AutoMemberSystem_NoGenderRole_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "A user without a pronoun role should not be granted membership")] + public static async Task AutoMemberSystem_NoPronounRole_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus) + .HasPostedIntroduction() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "A user with a warning should not be granted membership")] + public static async Task AutoMemberSystem_UserWithGaiusWarning_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .HasBeenWarned() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "A user with a caselog should not be granted membership")] + public static async Task AutoMemberSystem_UserWithGaiusCaselog_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .HasBeenPunished() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "A user should be granted membership if Gaius is unavailable")] + public static async Task AutoMemberSystem_GaiusIsUnavailable_ShouldBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .InhibitGaius() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertMember(); + } + + [Fact(DisplayName = "A user should be granted membership if they have been granted membership before")] + public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(1)) + .FirstJoined(TimeSpan.FromDays(7)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .HasBeenGrantedMembershipBefore() + .WithMessages(100) + .Build(); + + await SetupTest(context); + + // Act + var service = context.DiscordService as MockDiscordService; + var user = context.User; + + service.Should().NotBeNull(); + user.Should().NotBeNull(); + + await service.TriggerUserJoined(user); + + // Assert + context.AssertMember(); + } + + private record AutoMemberSystemContext( + Snowflake UserID, + int HoursSinceJoined, + Snowflake[] Roles, + bool PostedIntroduction, + int MessagesLast24Hours, + int FirstJoinTime, + bool GrantedMembershipBefore, + TestContext TestContext, + InstarDynamicConfiguration Config) + { + public static AutoMemberSystemContextBuilder Builder() => new(); + + public IDiscordService? DiscordService { get; set; } + public IGuildUser User { get; set; } = null!; + + public void AssertMember() + { + User.Should().NotBeNull(); + User.RoleIds.Should().Contain(Member.ID); + User.RoleIds.Should().NotContain(NewMember.ID); + } + + public void AssertNotMember() + { + User.Should().NotBeNull(); + User.RoleIds.Should().NotContain(Member.ID); + User.RoleIds.Should().Contain(NewMember.ID); + } + + public void AssertUserUnchanged() + { + var testUser = User as TestGuildUser; + testUser.Should().NotBeNull(); + testUser.Changed.Should().BeFalse(); + } + } + + private class AutoMemberSystemContextBuilder + { + private int _hoursSinceJoined; + private Snowflake[]? _roles; + private bool _postedIntroduction; + private int _messagesLast24Hours; + private bool _gaiusAvailable = true; + private bool _gaiusPunished; + private bool _gaiusWarned; + private int _firstJoinTime; + private bool _grantedMembershipBefore; + + public AutoMemberSystemContextBuilder Joined(TimeSpan timeAgo) + { + _hoursSinceJoined = (int) Math.Round(timeAgo.TotalHours); + return this; + } + public AutoMemberSystemContextBuilder SetRoles(params Snowflake[] roles) + { + _roles = roles; + return this; + } + + public AutoMemberSystemContextBuilder HasPostedIntroduction() + { + _postedIntroduction = true; + return this; + } + + public AutoMemberSystemContextBuilder WithMessages(int messages) + { + _messagesLast24Hours = messages; + return this; + } + + public AutoMemberSystemContextBuilder InhibitGaius() + { + _gaiusAvailable = false; + return this; + } + + public AutoMemberSystemContextBuilder HasBeenPunished() + { + _gaiusPunished = true; + return this; + } + + public AutoMemberSystemContextBuilder HasBeenWarned() + { + _gaiusWarned = true; + return this; + } + + public AutoMemberSystemContextBuilder FirstJoined(TimeSpan hoursAgo) + { + _firstJoinTime = (int) Math.Round(hoursAgo.TotalHours); + return this; + } + + public AutoMemberSystemContextBuilder HasBeenGrantedMembershipBefore() + { + _grantedMembershipBefore = true; + return this; + } + + public async Task Build() + { + var config = await TestUtilities.GetDynamicConfiguration().GetConfig(); + + var testContext = new TestContext(); + + var userId = Snowflake.Generate(); + + // Set up any warnings or whatnot + testContext.InhibitGaius = !_gaiusAvailable; + + if (_gaiusPunished) + { + testContext.AddCaselog(userId, new Caselog + { + Type = CaselogType.Mute, + Reason = "TEST PUNISHMENT", + ModID = Snowflake.Generate(), + UserID = userId, + }); + } + if (_gaiusWarned) + { + testContext.AddWarning(userId, new Warning() + { + Reason = "TEST PUNISHMENT", + ModID = Snowflake.Generate(), + UserID = userId, + }); + } + + return new AutoMemberSystemContext( + userId, + _hoursSinceJoined, + _roles ?? throw new InvalidOperationException("Roles must be set."), + _postedIntroduction, + _messagesLast24Hours, + _firstJoinTime, + _grantedMembershipBefore, + testContext, + config); + } + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs new file mode 100644 index 0000000..c56d268 --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -0,0 +1,225 @@ +using Discord; +using InstarBot.Tests.Services; +using Moq; +using Moq.Protected; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using Xunit; + +namespace InstarBot.Tests.Integration.Interactions; + +public class PageCommandTests +{ + private static async Task> SetupCommandMock(PageCommandTestContext context) + { + // Treat the Test page target as a regular non-staff user on the server + var userTeam = context.UserTeamID == PageTarget.Test + ? Snowflake.Generate() + : (await TestUtilities.GetTeams(context.UserTeamID).FirstAsync()).ID; + + var commandMock = TestUtilities.SetupCommandMock( + () => new PageCommand(TestUtilities.GetTeamService(), new MockMetricService()), + new TestContext + { + UserRoles = [userTeam] + }); + + return commandMock; + } + + private static async Task GetTeamLead(PageTarget pageTarget) + { + var dynamicConfig = await TestUtilities.GetDynamicConfiguration().GetConfig(); + + var teamsConfig = + dynamicConfig.Teams.ToDictionary(n => n.InternalID, n => n); + + // Eeeeeeeeeeeeevil + return teamsConfig[pageTarget.GetAttributesOfType()?.First().InternalID ?? "idkjustfail"] + .Teamleader; + } + + private static async Task VerifyPageEmbedEmitted(Mock command, PageCommandTestContext context) + { + var pageTarget = context.PageTarget; + + string expectedString; + + if (context.PagingTeamLeader) + expectedString = $"<@{await GetTeamLead(pageTarget)}>"; + else + switch (pageTarget) + { + case PageTarget.All: + expectedString = string.Join(' ', + await TestUtilities.GetTeams(PageTarget.All).Select(n => Snowflake.GetMention(() => n.ID)) + .ToArrayAsync()); + break; + case PageTarget.Test: + expectedString = "This is a __**TEST**__ page."; + break; + default: + var team = await TestUtilities.GetTeams(pageTarget).FirstAsync(); + expectedString = Snowflake.GetMention(() => team.ID); + break; + } + + command.Protected().Verify( + "RespondAsync", Times.Once(), + expectedString, ItExpr.IsNull(), + false, false, AllowedMentions.All, ItExpr.IsNull(), + ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); + } + + [Fact(DisplayName = "User should be able to page when authorized")] + public static async Task PageCommand_Authorized_WhenPagingTeam_ShouldPageCorrectly() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Owner, + PageTarget.Moderator, + false + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + + // Assert + await VerifyPageEmbedEmitted(command, context); + } + + [Fact(DisplayName = "User should be able to page a team's teamleader")] + public static async Task PageCommand_Authorized_WhenPagingTeamLeader_ShouldPageCorrectly() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Owner, + PageTarget.Moderator, + true + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + + // Assert + await VerifyPageEmbedEmitted(command, context); + } + + [Fact(DisplayName = "Any staff member should be able to use the Test page")] + public static async Task PageCommand_AnyStaffMember_WhenPagingTest_ShouldPageCorrectly() + { + var targets = Enum.GetValues().Except([PageTarget.All, PageTarget.Test]); + + foreach (var userTeam in targets) + { + // Arrange + var context = new PageCommandTestContext( + userTeam, + PageTarget.Test, + false + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, + string.Empty); + + // Assert + await VerifyPageEmbedEmitted(command, context); + } + } + + [Fact(DisplayName = "Owner should be able to page all")] + public static async Task PageCommand_Authorized_WhenOwnerPagingAll_ShouldPageCorrectly() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Owner, + PageTarget.All, + false + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + + // Assert + await VerifyPageEmbedEmitted(command, context); + } + + [Fact(DisplayName = "Fail page if paging all teamleader")] + public static async Task PageCommand_Authorized_WhenOwnerPagingAllTeamLead_ShouldFail() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Owner, + PageTarget.All, + true + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + + // Assert + TestUtilities.VerifyMessage( + command, + "Failed to send page. The 'All' team does not have a teamleader. If intended to page the owner, please select the Owner as the team.", + true + ); + } + + [Fact(DisplayName = "Unauthorized user should receive an error message")] + public static async Task PageCommand_Unauthorized_WhenAttemptingToPage_ShouldFail() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Test, + PageTarget.Moderator, + false + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + + // Assert + TestUtilities.VerifyMessage( + command, + "You are not authorized to use this command.", + true + ); + } + + [Fact(DisplayName = "Helper should not be able to page all")] + public static async Task PageCommand_Authorized_WhenHelperAttemptsToPageAll_ShouldFail() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Helper, + PageTarget.All, + false + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + + // Assert + TestUtilities.VerifyMessage( + command, + "You are not authorized to send a page to the entire staff team.", + true + ); + } + private record PageCommandTestContext(PageTarget UserTeamID, PageTarget PageTarget, bool PagingTeamLeader); +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs new file mode 100644 index 0000000..8ea3d75 --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs @@ -0,0 +1,26 @@ +using PaxAndromeda.Instar.Commands; + +namespace InstarBot.Tests.Integration.Interactions; +using Xunit; + +public sealed class PingCommandTests +{ + /// + /// Tests that the ping command emits an ephemeral "Pong!" response. + /// + /// This test verifies that calling the ping command results in the + /// expected ephemeral "Pong!" response. + /// + [Fact(DisplayName = "User should be able to issue the Ping command.")] + public static async Task PingCommand_Send_ShouldEmitEphemeralPong() + { + // Arrange + var command = TestUtilities.SetupCommandMock(); + + // Act + await command.Object.Ping(); + + // Assert + TestUtilities.VerifyMessage(command, "Pong!", true); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs new file mode 100644 index 0000000..195d94f --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -0,0 +1,180 @@ +using Discord; +using FluentAssertions; +using InstarBot.Tests.Services; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.Modals; +using Xunit; + +namespace InstarBot.Tests.Integration.Interactions; + +public sealed class ReportUserTests +{ + [Fact(DisplayName = "User should be able to report a message normally")] + public static async Task ReportUser_WhenReportingNormally_ShouldNotifyStaff() + { + var context = ReportContext.Builder() + .FromUser(Snowflake.Generate()) + .Reporting(Snowflake.Generate()) + .InChannel(Snowflake.Generate()) + .WithReason("This is a test report") + .Build(); + + var (command, interactionContext, channelMock) = SetupMocks(context); + + // Act + await command.Object.HandleCommand(interactionContext.Object); + await command.Object.ModalResponse(new ReportMessageModal + { + ReportReason = context.Reason + }); + + // Assert + TestUtilities.VerifyMessage(command, "Your report has been sent.", true); + + channelMock.Verify(n => n.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())); + + Assert.NotNull(context.ResultEmbed); + var embed = context.ResultEmbed; + + embed.Author.Should().NotBeNull(); + embed.Footer.Should().NotBeNull(); + embed.Footer!.Value.Text.Should().Be("Instar Message Reporting System"); + } + + [Fact(DisplayName = "Report user function times out if cache expires")] + public static async Task ReportUser_WhenReportingNormally_ShouldFail_IfNotCompletedWithin5Minutes() + { + var context = ReportContext.Builder() + .FromUser(Snowflake.Generate()) + .Reporting(Snowflake.Generate()) + .InChannel(Snowflake.Generate()) + .WithReason("This is a test report") + .Build(); + + var (command, interactionContext, _) = SetupMocks(context); + + // Act + await command.Object.HandleCommand(interactionContext.Object); + ReportUserCommand.PurgeCache(); + await command.Object.ModalResponse(new ReportMessageModal + { + ReportReason = context.Reason + }); + + // Assert + TestUtilities.VerifyMessage(command, "Report expired. Please try again.", true); + } + + private static (Mock, Mock, Mock) SetupMocks(ReportContext context) + { + var commandMockContext = new TestContext + { + UserID = context.User, + EmbedCallback = embed => context.ResultEmbed = embed, + }; + + var commandMock = + TestUtilities.SetupCommandMock + (() => new ReportUserCommand(TestUtilities.GetDynamicConfiguration(), new MockMetricService()), + commandMockContext); + + return (commandMock, SetupMessageCommandMock(context), commandMockContext.TextChannelMock); + } + + private static Mock SetupMessageCommandMock(ReportContext context) + { + var userMock = TestUtilities.SetupUserMock(context.User); + var authorMock = TestUtilities.SetupUserMock(context.Sender); + + var channelMock = TestUtilities.SetupChannelMock(context.Channel); + + var messageMock = new Mock(); + messageMock.Setup(n => n.Id).Returns(100); + messageMock.Setup(n => n.Author).Returns(authorMock.Object); + messageMock.Setup(n => n.Channel).Returns(channelMock.Object); + + var socketMessageDataMock = new Mock(); + socketMessageDataMock.Setup(n => n.Message).Returns(messageMock.Object); + + var socketMessageCommandMock = new Mock(); + socketMessageCommandMock.Setup(n => n.User).Returns(userMock.Object); + socketMessageCommandMock.Setup(n => n.Data).Returns(socketMessageDataMock.Object); + + socketMessageCommandMock.Setup(n => + n.RespondWithModalAsync(It.IsAny(), It.IsAny(), + It.IsAny>())) + .Returns(Task.CompletedTask); + + return socketMessageCommandMock; + } + + private record ReportContext(Snowflake User, Snowflake Sender, Snowflake Channel, string Reason) + { + public static ReportContextBuilder Builder() + { + return new ReportContextBuilder(); + } + + public Embed? ResultEmbed { get; set; } + } + + private class ReportContextBuilder + { + private Snowflake? _user; + private Snowflake? _sender; + private Snowflake? _channel; + private string? _reason; + + /* + * ReportContext.Builder() + * .FromUser(user) + * .Reporting(userToReport) + * .WithContent(content) + * .InChannel(channel) + * .WithReason(reason); + */ + public ReportContextBuilder FromUser(Snowflake user) + { + _user = user; + return this; + } + + public ReportContextBuilder Reporting(Snowflake userToReport) + { + _sender = userToReport; + return this; + } + + public ReportContextBuilder InChannel(Snowflake channel) + { + _channel = channel; + return this; + } + + public ReportContextBuilder WithReason(string reason) + { + _reason = reason; + return this; + } + + public ReportContext Build() + { + if (_user is null) + throw new InvalidOperationException("User must be set before building ReportContext"); + if (_sender is null) + throw new InvalidOperationException("Sender must be set before building ReportContext"); + if (_channel is null) + throw new InvalidOperationException("Channel must be set before building ReportContext"); + if (_reason is null) + throw new InvalidOperationException("Reason must be set before building ReportContext"); + + return new ReportContext(_user, _sender, _channel, _reason); + } + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs new file mode 100644 index 0000000..a7b7247 --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -0,0 +1,111 @@ +using FluentAssertions; +using InstarBot.Tests.Services; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.Services; +using Xunit; + +namespace InstarBot.Tests.Integration.Interactions; + +public class SetBirthdayCommandTests +{ + private static (IInstarDDBService, Mock) SetupMocks(SetBirthdayContext context) + { + var ddbService = TestUtilities.GetServices().GetService(); + var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService!, new MockMetricService()), new TestContext + { + UserID = context.User.ID, + }); + + Assert.NotNull(ddbService); + + return (ddbService, cmd); + } + + + [Theory(DisplayName = "User should be able to set their birthday when providing a valid date.")] + [InlineData(1992, 7, 21, 0)] + [InlineData(1992, 7, 21, -7)] + [InlineData(1992, 7, 21, 7)] + [InlineData(2000, 7, 21, 0)] + [InlineData(2001, 12, 31, 0)] + [InlineData(2010, 1, 1, 0)] + public static async Task SetBirthdayCommand_WithValidDate_ShouldSetCorrectly(int year, int month, int day, int timezone) + { + // Arrange + var context = new SetBirthdayContext(Snowflake.Generate(), year, month, day, timezone); + + var (ddb, cmd) = SetupMocks(context); + + // Act + await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); + + // Assert + var date = context.ToDateTime(); + (await ddb.GetUserBirthday(context.User.ID)).Should().Be(date.UtcDateTime); + TestUtilities.VerifyMessage(cmd, $"Your birthday was set to {date.DateTime:dddd, MMMM d, yyy}.", true); + } + + [Theory(DisplayName = "Attempting to set an invalid day or month number should emit an error message.")] + [InlineData(1992, 13, 1)] // Invalid month + [InlineData(1992, -7, 1)] // Invalid month + [InlineData(1992, 1, 40)] // Invalid day + [InlineData(1992, 2, 31)] // Leap year + [InlineData(2028, 2, 31)] // Leap year + [InlineData(2032, 2, 31)] // Leap year + public static async Task SetBirthdayCommand_WithInvalidDate_ShouldReturnError(int year, int month, int day) + { + // Arrange + var context = new SetBirthdayContext(Snowflake.Generate(), year, month, day); + + var (_, cmd) = SetupMocks(context); + + // Act + await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); + + // Assert + if (month is < 0 or > 12) + { + TestUtilities.VerifyMessage(cmd, + "There are only 12 months in a year. Your birthday was not set.", true); + } + else + { + var date = new DateTime(context.Year, context.Month, 1); // there's always a 1st of the month + var daysInMonth = DateTime.DaysInMonth(context.Year, context.Month); + + // Assert + TestUtilities.VerifyMessage(cmd, + $"There are only {daysInMonth} days in {date:MMMM yyy}. Your birthday was not set.", true); + } + } + + [Fact(DisplayName = "Attempting to set a birthday in the future should emit an error message.")] + public static async Task SetBirthdayCommand_WithDateInFuture_ShouldReturnError() + { + // Arrange + // Note: Update this in the year 9,999 + var context = new SetBirthdayContext(Snowflake.Generate(), 9999, 1, 1); + + var (_, cmd) = SetupMocks(context); + + // Act + await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); + + // Assert + TestUtilities.VerifyMessage(cmd, "You are not a time traveler. Your birthday was not set.", true); + } + + private record SetBirthdayContext(Snowflake User, int Year, int Month, int Day, int TimeZone = 0) + { + public DateTimeOffset ToDateTime() + { + var unspecifiedDate = new DateTime(Year, Month, Day, 0, 0, 0, DateTimeKind.Unspecified); + var timeZone = new DateTimeOffset(unspecifiedDate, TimeSpan.FromHours(TimeZone)); + + return timeZone; + } + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs deleted file mode 100644 index 8174ff6..0000000 --- a/InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs +++ /dev/null @@ -1,227 +0,0 @@ -using Discord; -using FluentAssertions; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.ConfigModels; -using PaxAndromeda.Instar.Gaius; -using PaxAndromeda.Instar.Services; - -namespace InstarBot.Tests.Integration; - -[Binding] -public class AutoMemberSystemStepDefinitions(ScenarioContext scenarioContext) -{ - private readonly Dictionary _roleNameIDMap = new(); - - [Given("the roles as follows:")] - public void GivenTheRolesAsFollows(Table table) - { - foreach (var row in table.Rows) - { - _roleNameIDMap.Add(row["Role Name"], ulong.Parse(row["Role ID"])); - } - } - - private async Task SetupTest() - { - var context = scenarioContext.Get("Context"); - var discordService = TestUtilities.SetupDiscordService(context); - var gaiusApiService = TestUtilities.SetupGaiusAPIService(context); - var config = TestUtilities.GetDynamicConfiguration(); - scenarioContext.Add("Config", config); - scenarioContext.Add("DiscordService", discordService); - - var userId = scenarioContext.Get("UserID"); - var relativeJoinTime = scenarioContext.Get("UserAge"); - var roles = scenarioContext.Get("UserRoles").Select(roleId => new Snowflake(roleId)).ToArray(); - var postedIntro = scenarioContext.Get("UserPostedIntroduction"); - var messagesLast24Hours = scenarioContext.Get("UserMessagesPast24Hours"); - var firstSeenTime = scenarioContext.ContainsKey("UserFirstJoinedTime") ? scenarioContext.Get("UserFirstJoinedTime") : 0; - var grantedMembershipBefore = scenarioContext.ContainsKey("UserGrantedMembershipBefore") && scenarioContext.Get("UserGrantedMembershipBefore"); - var amsConfig = scenarioContext.Get("AMSConfig"); - - var ddbService = new MockInstarDDBService(); - if (firstSeenTime > 0) - await ddbService.UpdateUserJoinDate(userId, DateTime.UtcNow - TimeSpan.FromHours(firstSeenTime)); - if (grantedMembershipBefore) - await ddbService.UpdateUserMembership(userId, grantedMembershipBefore); - - context.AddRoles(roles); - - var user = new TestGuildUser - { - Id = userId, - JoinedAt = DateTimeOffset.Now - TimeSpan.FromHours(relativeJoinTime), - RoleIds = roles.Select(n => n.ID).ToList().AsReadOnly() - }; - - context.GuildUsers.Add(user); - - - var genericChannel = Snowflake.Generate(); - context.AddChannel(amsConfig.IntroductionChannel); - - context.AddChannel(genericChannel); - if (postedIntro) - context.GetChannel(amsConfig.IntroductionChannel).AddMessage(user, "Some text"); - - for (var i = 0; i < messagesLast24Hours; i++) - context.GetChannel(genericChannel).AddMessage(user, "Some text"); - - - var ams = new AutoMemberSystem(config, discordService, gaiusApiService, ddbService, new MockMetricService()); - scenarioContext.Add("AutoMemberSystem", ams); - scenarioContext.Add("User", user); - - return ams; - } - - [When("the Auto Member System processes")] - public async Task WhenTheAutoMemberSystemProcesses() - { - var ams = await SetupTest(); - - await ams.RunAsync(); - } - - [Then("the user should remain unchanged")] - public void ThenTheUserShouldRemainUnchanged() - { - var userId = scenarioContext.Get("UserID"); - var context = scenarioContext.Get("Context"); - var user = context.GuildUsers.First(n => n.Id == userId.ID) as TestGuildUser; - - user.Should().NotBeNull(); - user.Changed.Should().BeFalse(); - } - - [Given("Been issued a warning")] - public void GivenBeenIssuedAWarning() - { - var userId = scenarioContext.Get("UserID"); - var context = scenarioContext.Get("Context"); - - context.AddWarning(userId, new Warning - { - Reason = "TEST WARNING", - ModID = Snowflake.Generate(), - UserID = userId - }); - } - - [Given("Been issued a mute")] - public void GivenBeenIssuedAMute() - { - var userId = scenarioContext.Get("UserID"); - var context = scenarioContext.Get("Context"); - - context.AddCaselog(userId, new Caselog - { - Type = CaselogType.Mute, - Reason = "TEST WARNING", - ModID = Snowflake.Generate(), - UserID = userId - }); - } - - [Given("the Gaius API is not available")] - public void GivenTheGaiusApiIsNotAvailable() - { - var context = scenarioContext.Get("Context"); - context.InhibitGaius = true; - } - - [Given("a user that has:")] - public async Task GivenAUserThatHas() - { - var config = await TestUtilities.GetDynamicConfiguration().GetConfig(); - var amsConfig = config.AutoMemberConfig; - scenarioContext.Add("AMSConfig", amsConfig); - - var cmc = new TestContext(); - scenarioContext.Add("Context", cmc); - - var userId = Snowflake.Generate(); - scenarioContext.Add("UserID", userId); - } - - [Given("Joined (.*) hours ago")] - public void GivenJoinedHoursAgo(int ageHours) => scenarioContext.Add("UserAge", ageHours); - - [Given("The roles (.*)")] - public void GivenTheRoles(string roles) - { - var roleNames = roles.Split([",", "and"], - StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - - var roleIds = roleNames.Select(roleName => _roleNameIDMap[roleName]).ToArray(); - - scenarioContext.Add("UserRoles", roleIds); - } - - [Given("Posted an introduction")] - public void GivenPostedAnIntroduction() => scenarioContext.Add("UserPostedIntroduction", true); - - [Given("Did not post an introduction")] - public void GivenDidNotPostAnIntroduction() => scenarioContext.Add("UserPostedIntroduction", false); - - [Given("Posted (.*) messages in the past day")] - public void GivenPostedMessagesInThePastDay(int numMessages) => scenarioContext.Add("UserMessagesPast24Hours", numMessages); - - [Then("the user should be granted membership")] - public async Task ThenTheUserShouldBeGrantedMembership() - { - var userId = scenarioContext.Get("UserID"); - var context = scenarioContext.Get("Context"); - var config = scenarioContext.Get("Config"); - var user = context.GuildUsers.First(n => n.Id == userId.ID); - - var cfg = await config.GetConfig(); - - user.RoleIds.Should().Contain(cfg.MemberRoleID); - user.RoleIds.Should().NotContain(cfg.NewMemberRoleID); - } - - [Then("the user should not be granted membership")] - public async Task ThenTheUserShouldNotBeGrantedMembership() - { - var userId = scenarioContext.Get("UserID"); - var context = scenarioContext.Get("Context"); - var config = scenarioContext.Get("Config"); - var user = context.GuildUsers.First(n => n.Id == userId.ID); - - var cfg = await config.GetConfig(); - - user.RoleIds.Should().Contain(cfg.NewMemberRoleID); - user.RoleIds.Should().NotContain(cfg.MemberRoleID); - } - - [Given("Not been punished")] - public void GivenNotBeenPunished() - { - // ignore - } - - [Given("First joined (.*) hours ago")] - public void GivenFirstJoinedHoursAgo(int hoursAgo) => scenarioContext.Add("UserFirstJoinedTime", hoursAgo); - - [Given("Joined the server for the first time")] - public void GivenJoinedTheServerForTheFirstTime() => scenarioContext.Add("UserFirstJoinedTime", 0); - - [Given("Been granted membership before")] - public void GivenBeenGrantedMembershipBefore() => scenarioContext.Add("UserGrantedMembershipBefore", true); - - [Given("Not been granted membership before")] - public void GivenNotBeenGrantedMembershipBefore() => scenarioContext.Add("UserGrantedMembershipBefore", false); - - [When("the user joins the server")] - public async Task WhenTheUserJoinsTheServer() - { - await SetupTest(); - var service = scenarioContext.Get("DiscordService") as MockDiscordService; - var user = scenarioContext.Get("User"); - - service?.TriggerUserJoined(user); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs deleted file mode 100644 index 064ac00..0000000 --- a/InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs +++ /dev/null @@ -1,72 +0,0 @@ -using FluentAssertions; -using InstarBot.Tests.Services; -using Microsoft.Extensions.DependencyInjection; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Commands; -using PaxAndromeda.Instar.Services; -using TechTalk.SpecFlow.Assist; -using Xunit; - -namespace InstarBot.Tests.Integration; - -[Binding] -public class BirthdayCommandStepDefinitions(ScenarioContext context) -{ - [Given("the user provides the following parameters")] - public void GivenTheUserProvidesTheFollowingParameters(Table table) - { - var dict = table.Rows.ToDictionary(n => n["Key"], n => n.GetInt32("Value")); - - // Let's see if we have the bare minimum - Assert.True(dict.ContainsKey("Year") && dict.ContainsKey("Month") && dict.ContainsKey("Day")); - - context.Add("Year", dict["Year"]); - context.Add("Month", dict["Month"]); - context.Add("Day", dict["Day"]); - - if (dict.TryGetValue("Timezone", out var value)) - context.Add("Timezone", value); - } - - [When("the user calls the Set Birthday command")] - public async Task WhenTheUserCallsTheSetBirthdayCommand() - { - var year = context.Get("Year"); - var month = context.Get("Month"); - var day = context.Get("Day"); - var timezone = context.ContainsKey("Timezone") ? context.Get("Timezone") : 0; - - var userId = new Snowflake().ID; - - var ddbService = TestUtilities.GetServices().GetService(); - var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService!, new MockMetricService()), new TestContext - { - UserID = userId - }); - context.Add("Command", cmd); - context.Add("UserID", userId); - context.Add("DDB", ddbService); - - await cmd.Object.SetBirthday((Month)month, day, year, timezone); - } - - [Then("DynamoDB should have the user's (Birthday|JoinDate) set to (.*)")] - public async Task ThenDynamoDbShouldHaveBirthdaySetTo(string dataType, DateTime time) - { - var ddbService = context.Get("DDB"); - var userId = context.Get("UserID"); - - switch (dataType) - { - case "Birthday": - (await ddbService!.GetUserBirthday(userId)).Should().Be(time.ToUniversalTime()); - break; - case "JoinDate": - (await ddbService!.GetUserJoinDate(userId)).Should().Be(time.ToUniversalTime()); - break; - default: - Assert.Fail("Invalid test setup: dataType is unknown"); - break; - } - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs deleted file mode 100644 index 23ca192..0000000 --- a/InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Discord; -using FluentAssertions; -using InstarBot.Tests.Services; -using Moq; -using Moq.Protected; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Commands; - -namespace InstarBot.Tests.Integration; - -[Binding] -public sealed class PageCommandStepDefinitions(ScenarioContext scenarioContext) -{ - [Given("the user is in team (.*)")] - public async Task GivenTheUserIsInTeam(PageTarget target) - { - var team = await TestUtilities.GetTeams(target).FirstAsync(); - scenarioContext.Add("UserTeamID", team.ID); - } - - [Given("the user is not a staff member")] - public void GivenTheUserIsNotAStaffMember() - { - scenarioContext.Add("UserTeamID", new Snowflake()); - } - - [Given("the user is paging (Helper|Moderator|Admin|Owner|Test|All)")] - [SuppressMessage("ReSharper", "SpecFlow.MethodNameMismatchPattern")] - public void GivenTheUserIsPaging(PageTarget target) - { - scenarioContext.Add("PageTarget", target); - scenarioContext.Add("PagingTeamLeader", false); - } - - [Given("the user is paging the (Helper|Moderator|Admin|Owner|Test|All) teamleader")] - [SuppressMessage("ReSharper", "SpecFlow.MethodNameMismatchPattern")] - public void GivenTheUserIsPagingTheTeamTeamleader(PageTarget target) - { - scenarioContext.Add("PageTarget", target); - scenarioContext.Add("PagingTeamLeader", true); - } - - [When("the user calls the Page command")] - public async Task WhenTheUserCallsThePageCommand() - { - scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); - scenarioContext.ContainsKey("PagingTeamLeader").Should().BeTrue(); - var pageTarget = scenarioContext.Get("PageTarget"); - var pagingTeamLeader = scenarioContext.Get("PagingTeamLeader"); - - var command = SetupMocks(); - scenarioContext.Add("Command", command); - - await command.Object.Page(pageTarget, "This is a test reason", pagingTeamLeader); - } - - [Then("Instar should emit a valid Page embed")] - public async Task ThenInstarShouldEmitAValidPageEmbed() - { - scenarioContext.ContainsKey("Command").Should().BeTrue(); - scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); - var command = scenarioContext.Get>("Command"); - var pageTarget = scenarioContext.Get("PageTarget"); - - string expectedString; - - if (pageTarget == PageTarget.Test) - { - expectedString = "This is a __**TEST**__ page."; - } - else - { - var team = await TestUtilities.GetTeams(pageTarget).FirstAsync(); - expectedString = Snowflake.GetMention(() => team.ID); - } - - command.Protected().Verify( - "RespondAsync", Times.Once(), - expectedString, ItExpr.IsNull(), - false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); - } - - [Then("Instar should emit a valid teamleader Page embed")] - public async Task ThenInstarShouldEmitAValidTeamleaderPageEmbed() - { - scenarioContext.ContainsKey("Command").Should().BeTrue(); - scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); - - var command = scenarioContext.Get>("Command"); - var pageTarget = scenarioContext.Get("PageTarget"); - - command.Protected().Verify( - "RespondAsync", Times.Once(), - $"<@{await GetTeamLead(pageTarget)}>", ItExpr.IsNull(), - false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); - } - - private static async Task GetTeamLead(PageTarget pageTarget) - { - var dynamicConfig = await TestUtilities.GetDynamicConfiguration().GetConfig(); - - var teamsConfig = - dynamicConfig.Teams.ToDictionary(n => n.InternalID, n => n); - - // Eeeeeeeeeeeeevil - return teamsConfig[pageTarget.GetAttributesOfType()?.First().InternalID ?? "idkjustfail"] - .Teamleader; - } - - [Then("Instar should emit a valid All Page embed")] - public async Task ThenInstarShouldEmitAValidAllPageEmbed() - { - scenarioContext.ContainsKey("Command").Should().BeTrue(); - var command = scenarioContext.Get>("Command"); - var expected = string.Join(' ', - await TestUtilities.GetTeams(PageTarget.All).Select(n => Snowflake.GetMention(() => n.ID)).ToArrayAsync()); - - command.Protected().Verify( - "RespondAsync", Times.Once(), - expected, ItExpr.IsNull(), - false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); - } - - private Mock SetupMocks() - { - var userTeam = scenarioContext.Get("UserTeamID"); - - var commandMock = TestUtilities.SetupCommandMock( - () => new PageCommand(TestUtilities.GetTeamService(), new MockMetricService()), - new TestContext - { - UserRoles = [userTeam] - }); - - return commandMock; - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs deleted file mode 100644 index ff96def..0000000 --- a/InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using PaxAndromeda.Instar.Commands; - -namespace InstarBot.Tests.Integration; - -[Binding] -public class PingCommandStepDefinitions(ScenarioContext context) -{ - [When("the user calls the Ping command")] - public async Task WhenTheUserCallsThePingCommand() - { - var command = TestUtilities.SetupCommandMock(); - context.Add("Command", command); - - await command.Object.Ping(); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs deleted file mode 100644 index ddb7a73..0000000 --- a/InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Discord; -using FluentAssertions; -using InstarBot.Tests.Services; -using Moq; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Commands; -using PaxAndromeda.Instar.Modals; -using TechTalk.SpecFlow.Assist; -using Xunit; - -namespace InstarBot.Tests.Integration; - -[Binding] -public class ReportUserCommandStepDefinitions(ScenarioContext context) -{ - [When("the user (.*) reports a message with the following properties")] - public async Task WhenTheUserReportsAMessageWithTheFollowingProperties(ulong userId, Table table) - { - context.Add("ReportingUserID", userId); - - var messageProperties = table.Rows.ToDictionary(n => n["Key"], n => n); - - Assert.True(messageProperties.ContainsKey("Content")); - Assert.True(messageProperties.ContainsKey("Sender")); - Assert.True(messageProperties.ContainsKey("Channel")); - - context.Add("MessageContent", messageProperties["Content"].GetString("Value")); - context.Add("MessageSender", (ulong)messageProperties["Sender"].GetInt64("Value")); - context.Add("MessageChannel", (ulong)messageProperties["Channel"].GetInt64("Value")); - - var (command, interactionContext) = SetupMocks(); - context.Add("Command", command); - context.Add("InteractionContext", interactionContext); - - await command.Object.HandleCommand(interactionContext.Object); - } - - [When("does not complete the modal within 5 minutes")] - public async Task WhenDoesNotCompleteTheModalWithinMinutes() - { - Assert.True(context.ContainsKey("Command")); - var command = context.Get>("Command"); - - ReportUserCommand.PurgeCache(); - context.Add("ReportReason", string.Empty); - - await command.Object.ModalResponse(new ReportMessageModal - { - ReportReason = string.Empty - }); - } - - [When(@"completes the report modal with reason ""(.*)""")] - public async Task WhenCompletesTheReportModalWithReason(string reportReason) - { - Assert.True(context.ContainsKey("Command")); - var command = context.Get>("Command"); - context.Add("ReportReason", reportReason); - - await command.Object.ModalResponse(new ReportMessageModal - { - ReportReason = reportReason - }); - } - - [Then("Instar should emit a message report embed")] - public void ThenInstarShouldEmitAMessageReportEmbed() - { - Assert.True(context.ContainsKey("TextChannelMock")); - var textChannel = context.Get>("TextChannelMock"); - - textChannel.Verify(n => n.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny())); - - Assert.True(context.ContainsKey("ResultEmbed")); - var embed = context.Get("ResultEmbed"); - - embed.Author.Should().NotBeNull(); - embed.Footer.Should().NotBeNull(); - embed.Footer!.Value.Text.Should().Be("Instar Message Reporting System"); - } - - private (Mock, Mock) SetupMocks() - { - var commandMockContext = new TestContext - { - UserID = context.Get("ReportingUserID"), - EmbedCallback = embed => context.Add("ResultEmbed", embed) - }; - - var commandMock = - TestUtilities.SetupCommandMock - (() => new ReportUserCommand(TestUtilities.GetDynamicConfiguration(), new MockMetricService()), - commandMockContext); - context.Add("TextChannelMock", commandMockContext.TextChannelMock); - - return (commandMock, SetupMessageCommandMock()); - } - - private Mock SetupMessageCommandMock() - { - var userMock = TestUtilities.SetupUserMock(context.Get("ReportingUserID")); - var authorMock = TestUtilities.SetupUserMock(context.Get("MessageSender")); - - var channelMock = TestUtilities.SetupChannelMock(context.Get("MessageChannel")); - - var messageMock = new Mock(); - messageMock.Setup(n => n.Id).Returns(100); - messageMock.Setup(n => n.Author).Returns(authorMock.Object); - messageMock.Setup(n => n.Channel).Returns(channelMock.Object); - - var socketMessageDataMock = new Mock(); - socketMessageDataMock.Setup(n => n.Message).Returns(messageMock.Object); - - var socketMessageCommandMock = new Mock(); - socketMessageCommandMock.Setup(n => n.User).Returns(userMock.Object); - socketMessageCommandMock.Setup(n => n.Data).Returns(socketMessageDataMock.Object); - - socketMessageCommandMock.Setup(n => - n.RespondWithModalAsync(It.IsAny(), It.IsAny(), - It.IsAny>())) - .Returns(Task.CompletedTask); - - return socketMessageCommandMock; - } -} \ No newline at end of file diff --git a/InstarBot/Commands/SetBirthdayCommand.cs b/InstarBot/Commands/SetBirthdayCommand.cs index 40ef1b6..074ec21 100644 --- a/InstarBot/Commands/SetBirthdayCommand.cs +++ b/InstarBot/Commands/SetBirthdayCommand.cs @@ -30,6 +30,14 @@ public async Task SetBirthday( [Autocomplete] int tzOffset = 0) { + if ((int)month is < 0 or > 12) + { + await RespondAsync( + "There are only 12 months in a year. Your birthday was not set.", + ephemeral: true); + return; + } + var daysInMonth = DateTime.DaysInMonth(year, (int)month); // First step: Does the provided number of days exceed the number of days in the given month? @@ -41,8 +49,11 @@ await RespondAsync( return; } - var dtLocal = new DateTime(year, (int)month, day, 0, 0, 0, DateTimeKind.Unspecified); - var dtUtc = new DateTime(year, (int)month, day, 0, 0, 0, DateTimeKind.Utc).AddHours(-tzOffset); + var unspecifiedDate = new DateTime(year, (int)month, day, 0, 0, 0, DateTimeKind.Unspecified); + var dtZ = new DateTimeOffset(unspecifiedDate, TimeSpan.FromHours(tzOffset)); + + var dtLocal = dtZ.DateTime; + var dtUtc = dtZ.UtcDateTime; // Second step: Is the provided birthday actually in the future? if (dtUtc > DateTime.UtcNow) From c0299a5cf65a0a05177c4345515b766e38e1fdb1 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Wed, 2 Jul 2025 12:37:37 -0700 Subject: [PATCH 03/53] Refactored parts of AutoMemberSystem - PreloadIntroductionPosters now uses an enumerable based approach, optimizing the logic. - GetMessagesSent now uses a Linq method based approach for easy reading and efficiency. - Optimized recent messages lookup in CheckEligibility --- InstarBot/Services/AutoMemberSystem.cs | 44 ++++++++++++++++---------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index 63d320a..a7eb49b 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -279,7 +279,7 @@ public async Task CheckEligibility(IGuildUser user) if (!_introductionPosters.ContainsKey(user.Id)) eligibility |= MembershipEligibility.MissingIntroduction; - if (!_recentMessages.ContainsKey(user.Id) || _recentMessages[user.Id] < cfg.AutoMemberConfig.MinimumMessages) + if (_recentMessages.TryGetValue(user.Id, out int messages) && messages < cfg.AutoMemberConfig.MinimumMessages) eligibility |= MembershipEligibility.NotEnoughMessages; if (_punishedUsers.ContainsKey(user.Id)) @@ -306,13 +306,11 @@ private static bool CheckUserRequiredRoles(InstarDynamicConfiguration cfg, IGuil private Dictionary GetMessagesSent() { - var map = new Dictionary(); - - foreach (var cacheEntry in _messageCache) - { - if (!map.TryAdd(cacheEntry.Value.UserID, 1)) - map[cacheEntry.Value.UserID]++; - } + var map = this._messageCache + .Cast>() // Cast to access LINQ extensions + .Select(entry => entry.Value) + .GroupBy(properties => properties.UserID) + .ToDictionary(group => group.Key, group => group.Count()); return map; } @@ -347,24 +345,36 @@ private async Task PreloadIntroductionPosters(InstarDynamicConfiguration cfg) { if (await _discord.GetChannel(cfg.AutoMemberConfig.IntroductionChannel) is not ITextChannel introChannel) throw new InvalidOperationException("Introductions channel not found"); - - var messages = (await introChannel.GetMessagesAsync().FlattenAsync()).ToList(); + + var messages = (await introChannel.GetMessagesAsync().FlattenAsync()).GetEnumerator(); // Assumption: Last message is the oldest one - while (messages.Count > 0) + while (messages.MoveNext()) // Move to the first message, if there is any { - var oldestMessage = messages[0]; - foreach (var message in messages) + IMessage? message; + IMessage? oldestMessage = null; + + do { + message = messages.Current; + if (message is null) + break; + if (message.Author is IGuildUser sgUser && sgUser.RoleIds.Contains(cfg.MemberRoleID.ID)) continue; - + _introductionPosters.TryAdd(message.Author.Id, true); - if (message.Timestamp < oldestMessage.Timestamp) + + if (oldestMessage is null || message.Timestamp < oldestMessage.Timestamp) oldestMessage = message; - } + } while (messages.MoveNext()); + + if (message is null || oldestMessage is null) + break; - messages = (await introChannel.GetMessagesAsync(oldestMessage, Direction.Before).FlattenAsync()).ToList(); + messages = (await introChannel.GetMessagesAsync(oldestMessage, Direction.Before).FlattenAsync()).GetEnumerator(); } + + messages.Dispose(); } } \ No newline at end of file From 531a8741f166ba06d27b4d3f70f94d948777b698 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sun, 6 Jul 2025 09:37:56 -0700 Subject: [PATCH 04/53] Re-add Community Manager Team This commit partially reverts commit 20866da67d5dee92f47aeaebaadcb0342230ffcd --- .../Config/Instar.dynamic.test.conf.json | 8 ++++++++ .../Config/Instar.dynamic.test.debug.conf.json | 8 ++++++++ InstarBot/Commands/PageCommand.cs | 2 +- InstarBot/PageTarget.cs | 6 +++++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/InstarBot.Tests.Common/Config/Instar.dynamic.test.conf.json b/InstarBot.Tests.Common/Config/Instar.dynamic.test.conf.json index 5cac2dc..8a70ef5 100644 --- a/InstarBot.Tests.Common/Config/Instar.dynamic.test.conf.json +++ b/InstarBot.Tests.Common/Config/Instar.dynamic.test.conf.json @@ -93,6 +93,14 @@ "Teamleader": 459078815314870283, "Color": 13532979, "Priority": 4 + }, + { + "InternalID": "fe434e9a-2a69-41b6-a297-24e26ba4aebe", + "Name": "Community Manager", + "ID": 957411837920567356, + "Teamleader": 340546691491168257, + "Color": 10892756, + "Priority": 5 } ] } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json b/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json index 58b3e68..a8d4466 100644 --- a/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json +++ b/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json @@ -91,6 +91,14 @@ "Teamleader": 459078815314870283, "Color": 13532979, "Priority": 4 + }, + { + "InternalID": "fe434e9a-2a69-41b6-a297-24e26ba4aebe", + "Name": "Community Manager", + "ID": 957411837920567356, + "Teamleader": 340546691491168257, + "Color": 10892756, + "Priority": 5 } ] } \ No newline at end of file diff --git a/InstarBot/Commands/PageCommand.cs b/InstarBot/Commands/PageCommand.cs index 7818e81..453f338 100644 --- a/InstarBot/Commands/PageCommand.cs +++ b/InstarBot/Commands/PageCommand.cs @@ -97,7 +97,7 @@ private static bool CheckPermissions(IGuildUser user, Team? team, PageTarget pag return true; // Check permissions. Only mod+ can send an "all" page - if (team.Priority > 3 && pageTarget == PageTarget.All) // i.e. Helper + if (team.Priority > 3 && pageTarget == PageTarget.All) // i.e. Helper, Community Manager { response = "You are not authorized to send a page to the entire staff team."; Log.Information("{User} was not authorized to send a page to the entire staff team", user.Id); diff --git a/InstarBot/PageTarget.cs b/InstarBot/PageTarget.cs index 4b4d523..c0da141 100644 --- a/InstarBot/PageTarget.cs +++ b/InstarBot/PageTarget.cs @@ -1,4 +1,5 @@ -using JetBrains.Annotations; +using Discord.Interactions; +using JetBrains.Annotations; namespace PaxAndromeda.Instar; @@ -14,6 +15,9 @@ public enum PageTarget Moderator, [TeamRef("521dce27-9ed9-48fc-9615-dc1d77b72fdd"), UsedImplicitly] Helper, + [TeamRef("fe434e9a-2a69-41b6-a297-24e26ba4aebe"), UsedImplicitly] + [ChoiceDisplay("Community Manager")] + CommunityManager, [TeamRef("ffcf94e3-3080-455a-82e2-7cd9ec7eaafd")] [TeamRef("4e484ea5-3cd1-46d4-8fe8-666e34f251ad")] From 46e348ce79516f4daf0acba3f72d97bca397bf6b Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sun, 6 Jul 2025 10:56:57 -0700 Subject: [PATCH 05/53] Fixed some code quality and test adapter issues - Fixed a merge issue in GaiusAPIService.cs - Added necessary VS2022 test adapters to test packages --- .../InstarBot.Tests.Common.csproj | 3 +++ .../InstarBot.Tests.Integration.csproj | 2 ++ InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj | 6 ++++-- InstarBot/Services/GaiusAPIService.cs | 13 +++++++++---- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj index 90dc105..f16bfad 100644 --- a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj +++ b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj @@ -11,7 +11,10 @@ + + + diff --git a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj index 157a6ad..2041b84 100644 --- a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj +++ b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj @@ -10,6 +10,8 @@ + + all diff --git a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj index 7dc1491..dd7af44 100644 --- a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj +++ b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj @@ -14,6 +14,8 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -26,8 +28,8 @@ - - + + diff --git a/InstarBot/Services/GaiusAPIService.cs b/InstarBot/Services/GaiusAPIService.cs index 162e925..4738617 100644 --- a/InstarBot/Services/GaiusAPIService.cs +++ b/InstarBot/Services/GaiusAPIService.cs @@ -110,14 +110,17 @@ public async Task> GetCaselogsAfter(DateTime dt) return ParseCaselogs(result); } - private static IEnumerable ParseCaselogs(string response) + internal static IEnumerable ParseCaselogs(string response) { // Remove any instances of "totalCases" while (response.Contains("\"totalcases\":", StringComparison.OrdinalIgnoreCase)) { - var start = response.IndexOf("\"totalcases\":", StringComparison.OrdinalIgnoreCase); + var start = response.IndexOf("\"totalcases\":", StringComparison.OrdinalIgnoreCase); var end = response.IndexOfAny([',', '}'], start); + response = response.Remove(start, end - start + (response[end] == ',' ? 1 : 0)); + } + if (response.Length <= 2) yield break; @@ -139,8 +142,10 @@ private async Task Get(string url) private HttpRequestMessage CreateRequest(string url) { - var hrm = new HttpRequestMessage(); - hrm.RequestUri = new Uri(url); + var hrm = new HttpRequestMessage + { + RequestUri = new Uri(url) + }; hrm.Headers.Add("Accept", "application/json"); hrm.Headers.Add("api-key", _apiKey); From c7d29a4cc36c90ff933002ea8e03158deb32980b Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sun, 6 Jul 2025 11:40:06 -0700 Subject: [PATCH 06/53] Fixed some more code quality issues. --- InstarBot.Tests.Common/Models/TestMessage.cs | 60 +++++++++---------- .../Services/MockDiscordService.cs | 2 +- InstarBot.Tests.Common/TestContext.cs | 16 ++--- InstarBot.Tests.Common/TestUtilities.cs | 42 ++----------- .../Interactions/AutoMemberSystemTests.cs | 18 +++--- .../Interactions/PageCommandTests.cs | 2 +- .../Interactions/PingCommandTests.cs | 2 +- .../Interactions/ReportUserTests.cs | 4 +- .../Interactions/SetBirthdayCommandTests.cs | 4 +- .../RequireStaffMemberAttributeTests.cs | 7 ++- InstarBot/Commands/CheckEligibilityCommand.cs | 2 +- InstarBot/Program.cs | 2 +- InstarBot/Services/AutoMemberSystem.cs | 4 +- InstarBot/Services/CloudwatchMetricService.cs | 2 +- InstarBot/Services/GaiusAPIService.cs | 2 +- InstarBot/Utilities.cs | 6 +- 16 files changed, 69 insertions(+), 106 deletions(-) diff --git a/InstarBot.Tests.Common/Models/TestMessage.cs b/InstarBot.Tests.Common/Models/TestMessage.cs index 6f51fbb..f79f5e4 100644 --- a/InstarBot.Tests.Common/Models/TestMessage.cs +++ b/InstarBot.Tests.Common/Models/TestMessage.cs @@ -16,8 +16,8 @@ internal TestMessage(IUser user, string message) Content = message; } - public ulong Id { get; set; } - public DateTimeOffset CreatedAt { get; set; } + public ulong Id { get; } + public DateTimeOffset CreatedAt { get; } public Task DeleteAsync(RequestOptions options = null!) { @@ -54,34 +54,34 @@ public IAsyncEnumerable> GetReactionUsersAsync(IEmote throw new NotImplementedException(); } - public MessageType Type { get; set; } = default; - public MessageSource Source { get; set; } = default; - public bool IsTTS { get; set; } = false; - public bool IsPinned { get; set; } = false; - public bool IsSuppressed { get; set; } = false; - public bool MentionedEveryone { get; set; } = false; - public string Content { get; set; } - public string CleanContent { get; set; } = null!; - public DateTimeOffset Timestamp { get; set; } - public DateTimeOffset? EditedTimestamp { get; set; } = null; - public IMessageChannel Channel { get; set; } = null!; - public IUser Author { get; set; } - public IThreadChannel Thread { get; set; } = null!; - public IReadOnlyCollection Attachments { get; set; } = null!; - public IReadOnlyCollection Embeds { get; set; } = null!; - public IReadOnlyCollection Tags { get; set; } = null!; - public IReadOnlyCollection MentionedChannelIds { get; set; } = null!; - public IReadOnlyCollection MentionedRoleIds { get; set; } = null!; - public IReadOnlyCollection MentionedUserIds { get; set; } = null!; - public MessageActivity Activity { get; set; } = null!; - public MessageApplication Application { get; set; } = null!; - public MessageReference Reference { get; set; } = null!; - public IReadOnlyDictionary Reactions { get; set; } = null!; - public IReadOnlyCollection Components { get; set; } = null!; - public IReadOnlyCollection Stickers { get; set; } = null!; - public MessageFlags? Flags { get; set; } = null; - public IMessageInteraction Interaction { get; set; } = null!; - public MessageRoleSubscriptionData RoleSubscriptionData { get; set; } = null!; + public MessageType Type => default; + public MessageSource Source => default; + public bool IsTTS => false; + public bool IsPinned => false; + public bool IsSuppressed => false; + public bool MentionedEveryone => false; + public string Content { get; } + public string CleanContent => null!; + public DateTimeOffset Timestamp { get; } + public DateTimeOffset? EditedTimestamp => null; + public IMessageChannel Channel => null!; + public IUser Author { get; } + public IThreadChannel Thread => null!; + public IReadOnlyCollection Attachments => null!; + public IReadOnlyCollection Embeds => null!; + public IReadOnlyCollection Tags => null!; + public IReadOnlyCollection MentionedChannelIds => null!; + public IReadOnlyCollection MentionedRoleIds => null!; + public IReadOnlyCollection MentionedUserIds => null!; + public MessageActivity Activity => null!; + public MessageApplication Application => null!; + public MessageReference Reference => null!; + public IReadOnlyDictionary Reactions => null!; + public IReadOnlyCollection Components => null!; + public IReadOnlyCollection Stickers => null!; + public MessageFlags? Flags => null; + public IMessageInteraction Interaction => null!; + public MessageRoleSubscriptionData RoleSubscriptionData => null!; public PurchaseNotification PurchaseNotification => throw new NotImplementedException(); diff --git a/InstarBot.Tests.Common/Services/MockDiscordService.cs b/InstarBot.Tests.Common/Services/MockDiscordService.cs index 5f2ad8c..b1af3a7 100644 --- a/InstarBot.Tests.Common/Services/MockDiscordService.cs +++ b/InstarBot.Tests.Common/Services/MockDiscordService.cs @@ -53,7 +53,7 @@ public Task> GetAllUsers() public Task GetChannel(Snowflake channelId) { - return Task.FromResult(_guild.GetTextChannel(channelId) as IChannel); + return Task.FromResult(_guild.GetTextChannel(channelId)); } public async IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime) diff --git a/InstarBot.Tests.Common/TestContext.cs b/InstarBot.Tests.Common/TestContext.cs index 7babfc7..866f817 100644 --- a/InstarBot.Tests.Common/TestContext.cs +++ b/InstarBot.Tests.Common/TestContext.cs @@ -8,9 +8,9 @@ namespace InstarBot.Tests; public sealed class TestContext { - public ulong UserID { get; init; } = 1420070400100; - public ulong ChannelID { get; init; } = 1420070400200; - public ulong GuildID { get; init; } = 1420070400300; + public ulong UserID { get; init; }= 1420070400100; + public const ulong ChannelID = 1420070400200; + public const ulong GuildID = 1420070400300; public List UserRoles { get; init; } = []; @@ -18,13 +18,13 @@ public sealed class TestContext public Mock TextChannelMock { get; internal set; } = null!; - public List GuildUsers { get; init; } = []; + public List GuildUsers { get; } = []; - public Dictionary Channels { get; init; } = []; - public Dictionary Roles { get; init; } = []; + public Dictionary Channels { get; } = []; + public Dictionary Roles { get; } = []; - public Dictionary> Warnings { get; init; } = []; - public Dictionary> Caselogs { get; init; } = []; + public Dictionary> Warnings { get; } = []; + public Dictionary> Caselogs { get; } = []; public bool InhibitGaius { get; set; } diff --git a/InstarBot.Tests.Common/TestUtilities.cs b/InstarBot.Tests.Common/TestUtilities.cs index 5d3b918..153ac47 100644 --- a/InstarBot.Tests.Common/TestUtilities.cs +++ b/InstarBot.Tests.Common/TestUtilities.cs @@ -12,7 +12,6 @@ using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.Services; -using Xunit; namespace InstarBot.Tests; @@ -64,37 +63,6 @@ public static IServiceProvider GetServices() return sc.BuildServiceProvider(); } - /// - /// Provides a method for verifying messages with an ambiguous Mock type. - /// - /// A mockup of the command. - /// The message to search for. - /// A flag indicating whether the message should be ephemeral. - public static void VerifyMessage(object mockObject, string message, bool ephemeral = false) - { - // A few checks first - var mockObjectType = mockObject.GetType(); - Assert.Equal(nameof(Mock), mockObjectType.Name[..mockObjectType.Name.LastIndexOf('`')]); - Assert.Single(mockObjectType.GenericTypeArguments); - var commandType = mockObjectType.GenericTypeArguments[0]; - - var genericVerifyMessage = typeof(TestUtilities) - .GetMethods() - .Where(n => n.Name == nameof(VerifyMessage)) - .Select(m => new - { - Method = m, - Params = m.GetParameters(), - Args = m.GetGenericArguments() - }) - .Where(x => x.Args.Length == 1) - .Select(x => x.Method) - .First(); - - var specificMethod = genericVerifyMessage.MakeGenericMethod(commandType); - specificMethod.Invoke(null, [mockObject, message, ephemeral]); - } - /// /// Verifies that the command responded to the user with the correct . /// @@ -167,7 +135,7 @@ private static void ConfigureCommandMock(Mock mock, TestContext? context) .Returns(Task.CompletedTask); } - public static Mock SetupContext(TestContext? context) + public static Mock SetupContext(TestContext context) { var mock = new Mock(); @@ -184,7 +152,7 @@ private static Mock SetupGuildMock(TestContext? context) context.Should().NotBeNull(); var guildMock = new Mock(); - guildMock.Setup(n => n.Id).Returns(context.GuildID); + guildMock.Setup(n => n.Id).Returns(TestContext.GuildID); guildMock.Setup(n => n.GetTextChannel(It.IsAny())) .Returns(context.TextChannelMock.Object); @@ -220,10 +188,10 @@ public static Mock SetupChannelMock(ulong channelId) return channelMock; } - private static Mock SetupChannelMock(TestContext? context) + private static Mock SetupChannelMock(TestContext context) where T : class, IChannel { - var channelMock = SetupChannelMock(context!.ChannelID); + var channelMock = SetupChannelMock(TestContext.ChannelID); if (typeof(T) != typeof(ITextChannel)) return channelMock; @@ -263,7 +231,7 @@ public static async IAsyncEnumerable GetTeams(PageTarget pageTarget) foreach (var internalId in teamRefs) { - if (!teamsConfig.TryGetValue(internalId, out Team? value)) + if (!teamsConfig.TryGetValue(internalId, out var value)) throw new KeyNotFoundException("Failed to find team with internal ID " + internalId); yield return value; diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs index 72d6663..9d282d2 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs @@ -1,5 +1,4 @@ -using Discord; -using FluentAssertions; +using FluentAssertions; using InstarBot.Tests.Models; using InstarBot.Tests.Services; using PaxAndromeda.Instar; @@ -10,7 +9,7 @@ namespace InstarBot.Tests.Integration.Interactions; -public class AutoMemberSystemTests +public static class AutoMemberSystemTests { private static readonly Snowflake NewMember = new(796052052433698817); private static readonly Snowflake Member = new(793611808372031499); @@ -361,7 +360,7 @@ private record AutoMemberSystemContext( public static AutoMemberSystemContextBuilder Builder() => new(); public IDiscordService? DiscordService { get; set; } - public IGuildUser User { get; set; } = null!; + public TestGuildUser? User { get; set; } public void AssertMember() { @@ -379,9 +378,8 @@ public void AssertNotMember() public void AssertUserUnchanged() { - var testUser = User as TestGuildUser; - testUser.Should().NotBeNull(); - testUser.Changed.Should().BeFalse(); + User.Should().NotBeNull(); + User.Changed.Should().BeFalse(); } } @@ -468,16 +466,16 @@ public async Task Build() Type = CaselogType.Mute, Reason = "TEST PUNISHMENT", ModID = Snowflake.Generate(), - UserID = userId, + UserID = userId }); } if (_gaiusWarned) { - testContext.AddWarning(userId, new Warning() + testContext.AddWarning(userId, new Warning { Reason = "TEST PUNISHMENT", ModID = Snowflake.Generate(), - UserID = userId, + UserID = userId }); } diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index c56d268..8ca2607 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -8,7 +8,7 @@ namespace InstarBot.Tests.Integration.Interactions; -public class PageCommandTests +public static class PageCommandTests { private static async Task> SetupCommandMock(PageCommandTestContext context) { diff --git a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs index 8ea3d75..7885fc3 100644 --- a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs @@ -3,7 +3,7 @@ namespace InstarBot.Tests.Integration.Interactions; using Xunit; -public sealed class PingCommandTests +public static class PingCommandTests { /// /// Tests that the ping command emits an ephemeral "Pong!" response. diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs index 195d94f..53d3acd 100644 --- a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -9,7 +9,7 @@ namespace InstarBot.Tests.Integration.Interactions; -public sealed class ReportUserTests +public static class ReportUserTests { [Fact(DisplayName = "User should be able to report a message normally")] public static async Task ReportUser_WhenReportingNormally_ShouldNotifyStaff() @@ -76,7 +76,7 @@ private static (Mock, Mock, var commandMockContext = new TestContext { UserID = context.User, - EmbedCallback = embed => context.ResultEmbed = embed, + EmbedCallback = embed => context.ResultEmbed = embed }; var commandMock = diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs index a7b7247..4812d95 100644 --- a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -9,14 +9,14 @@ namespace InstarBot.Tests.Integration.Interactions; -public class SetBirthdayCommandTests +public static class SetBirthdayCommandTests { private static (IInstarDDBService, Mock) SetupMocks(SetBirthdayContext context) { var ddbService = TestUtilities.GetServices().GetService(); var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService!, new MockMetricService()), new TestContext { - UserID = context.User.ID, + UserID = context.User.ID }); Assert.NotNull(ddbService); diff --git a/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs b/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs index 8a0f55f..c2cb16b 100644 --- a/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs +++ b/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Moq; +using PaxAndromeda.Instar; using PaxAndromeda.Instar.Preconditions; using Xunit; @@ -24,7 +25,7 @@ public async Task CheckRequirementsAsync_ShouldReturnFalse_WithBadConfig() var context = TestUtilities.SetupContext(new TestContext { - UserRoles = [new(793607635608928257)] + UserRoles = [new Snowflake(793607635608928257)] }); // Act @@ -58,7 +59,7 @@ public async Task CheckRequirementsAsync_ShouldReturnSuccessful_WithValidUser() var context = TestUtilities.SetupContext(new TestContext { - UserRoles = [new(793607635608928257)] + UserRoles = [new Snowflake(793607635608928257)] }); // Act @@ -76,7 +77,7 @@ public async Task CheckRequirementsAsync_ShouldReturnFailure_WithNonStaffUser() var context = TestUtilities.SetupContext(new TestContext { - UserRoles = [new()] + UserRoles = [new Snowflake()] }); // Act diff --git a/InstarBot/Commands/CheckEligibilityCommand.cs b/InstarBot/Commands/CheckEligibilityCommand.cs index b8d97f0..4127f0d 100644 --- a/InstarBot/Commands/CheckEligibilityCommand.cs +++ b/InstarBot/Commands/CheckEligibilityCommand.cs @@ -91,7 +91,7 @@ private async Task BuildMissingItemsText(MembershipEligibility eligibili foreach (var roleGroup in config.AutoMemberConfig.RequiredRoles) { if (user.RoleIds.Intersect(roleGroup.Roles.Select(n => n.ID)).Any()) continue; - var prefix = "aeiouAEIOU".IndexOf(roleGroup.GroupName[0]) >= 0 ? "an" : "a"; // grammar hack :) + var prefix = "aeiouAEIOU".Contains(roleGroup.GroupName[0]) ? "an" : "a"; // grammar hack :) missingItemsBuilder.AppendLine( $"- You are missing {prefix} {roleGroup.GroupName.ToLowerInvariant()} role."); } diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index 0648c45..6c3520e 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -112,7 +112,7 @@ private static void CurrentDomainOnUnhandledException(object sender, UnhandledEx Log.Fatal(e.ExceptionObject as Exception, "FATAL: Unhandled exception caught"); } - private static IServiceProvider ConfigureServices(IConfiguration config) + private static ServiceProvider ConfigureServices(IConfiguration config) { var services = new ServiceCollection(); diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index a7eb49b..28ce01e 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -279,7 +279,7 @@ public async Task CheckEligibility(IGuildUser user) if (!_introductionPosters.ContainsKey(user.Id)) eligibility |= MembershipEligibility.MissingIntroduction; - if (_recentMessages.TryGetValue(user.Id, out int messages) && messages < cfg.AutoMemberConfig.MinimumMessages) + if (_recentMessages.TryGetValue(user.Id, out var messages) && messages < cfg.AutoMemberConfig.MinimumMessages) eligibility |= MembershipEligibility.NotEnoughMessages; if (_punishedUsers.ContainsKey(user.Id)) @@ -306,7 +306,7 @@ private static bool CheckUserRequiredRoles(InstarDynamicConfiguration cfg, IGuil private Dictionary GetMessagesSent() { - var map = this._messageCache + var map = _messageCache .Cast>() // Cast to access LINQ extensions .Select(entry => entry.Value) .GroupBy(properties => properties.UserID) diff --git a/InstarBot/Services/CloudwatchMetricService.cs b/InstarBot/Services/CloudwatchMetricService.cs index 724d61a..165c1dd 100644 --- a/InstarBot/Services/CloudwatchMetricService.cs +++ b/InstarBot/Services/CloudwatchMetricService.cs @@ -29,7 +29,7 @@ public async Task Emit(Metric metric, double value) var datum = new MetricDatum { - MetricName = nameAttr is not null ? nameAttr.Name : Enum.GetName(typeof(Metric), metric), + MetricName = nameAttr is not null ? nameAttr.Name : Enum.GetName(metric), Value = value }; diff --git a/InstarBot/Services/GaiusAPIService.cs b/InstarBot/Services/GaiusAPIService.cs index 4738617..48c4216 100644 --- a/InstarBot/Services/GaiusAPIService.cs +++ b/InstarBot/Services/GaiusAPIService.cs @@ -110,7 +110,7 @@ public async Task> GetCaselogsAfter(DateTime dt) return ParseCaselogs(result); } - internal static IEnumerable ParseCaselogs(string response) + private static IEnumerable ParseCaselogs(string response) { // Remove any instances of "totalCases" while (response.Contains("\"totalcases\":", StringComparison.OrdinalIgnoreCase)) diff --git a/InstarBot/Utilities.cs b/InstarBot/Utilities.cs index d2415e0..216c6d4 100644 --- a/InstarBot/Utilities.cs +++ b/InstarBot/Utilities.cs @@ -19,10 +19,6 @@ public static class Utilities { var type = enumVal.GetType(); var membersInfo = type.GetMember(enumVal.ToString()); - if (membersInfo.Length == 0) - return null; - - var attr = membersInfo[0].GetCustomAttribute(typeof(T), false); - return attr as T; + return membersInfo.Length == 0 ? null : membersInfo[0].GetCustomAttribute(false); } } \ No newline at end of file From 72ecdb2e54a6b5393811f97ecccd7fedb13a34eb Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sat, 22 Nov 2025 19:20:51 -0800 Subject: [PATCH 07/53] Added new functionality: - Updated Instar to .NET 10 / C# 14 - Updated backend database structure - Added - Updated /checkeligibility command - Fixed a bug where /checkeligibility fails if user is AMHed - New /eligibility command for staff to check the eligibility of members - AMH commands and related supporting code --- .../InstarBot.Tests.Common.csproj | 11 +- .../Models/TestGuildUser.cs | 6 + .../Services/MockAutoMemberSystem.cs | 19 ++ .../Services/MockDiscordService.cs | 35 ++- .../Services/MockInstarDDBService.cs | 66 +++-- InstarBot.Tests.Common/TestUtilities.cs | 19 +- InstarBot.Tests.Integration/Assembly.cs | 1 + .../InstarBot.Tests.Integration.csproj | 12 +- .../Interactions/AutoMemberSystemTests.cs | 137 +++++++-- .../CheckEligibilityCommandTests.cs | 169 +++++++++++ .../Interactions/PageCommandTests.cs | 2 +- .../Interactions/ReportUserTests.cs | 2 +- .../Interactions/SetBirthdayCommandTests.cs | 11 +- InstarBot.Tests.Unit/Assembly.cs | 3 + .../DynamoModels/EventListTests.cs | 123 ++++++++ .../InstarBot.Tests.Unit.csproj | 12 +- InstarBot.Tests.Unit/UtilitiesTests.cs | 22 ++ InstarBot/BadStateException.cs | 24 ++ InstarBot/Commands/AutoMemberHoldCommand.cs | 121 ++++++++ InstarBot/Commands/CheckEligibilityCommand.cs | 230 +++++++++++---- InstarBot/Commands/SetBirthdayCommand.cs | 26 +- .../TriggerAutoMemberSystemCommand.cs | 2 +- InstarBot/Config/Instar.conf.schema.json | 14 + .../ArbitraryDynamoDBTypeConverter.cs | 243 ++++++++++++++++ InstarBot/DynamoModels/EventList.cs | 72 +++++ InstarBot/DynamoModels/InstarDatabaseEntry.cs | 26 ++ InstarBot/DynamoModels/InstarUserData.cs | 267 ++++++++++++++++++ InstarBot/InstarBot.csproj | 40 +-- InstarBot/MembershipEligibility.cs | 18 +- InstarBot/Metrics/Metric.cs | 12 +- InstarBot/Modals/UserUpdatedEventArgs.cs | 19 ++ InstarBot/Program.cs | 6 +- InstarBot/Services/AutoMemberSystem.cs | 227 +++++++++++---- InstarBot/Services/CloudwatchMetricService.cs | 3 +- InstarBot/Services/DiscordService.cs | 39 ++- InstarBot/Services/IAutoMemberSystem.cs | 28 ++ InstarBot/Services/IDiscordService.cs | 4 +- InstarBot/Services/IInstarDDBService.cs | 42 ++- InstarBot/Services/InstarDDBService.cs | 119 ++------ InstarBot/Utilities.cs | 123 +++++++- 40 files changed, 2004 insertions(+), 351 deletions(-) create mode 100644 InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs create mode 100644 InstarBot.Tests.Integration/Assembly.cs create mode 100644 InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs create mode 100644 InstarBot.Tests.Unit/Assembly.cs create mode 100644 InstarBot.Tests.Unit/DynamoModels/EventListTests.cs create mode 100644 InstarBot.Tests.Unit/UtilitiesTests.cs create mode 100644 InstarBot/BadStateException.cs create mode 100644 InstarBot/Commands/AutoMemberHoldCommand.cs create mode 100644 InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs create mode 100644 InstarBot/DynamoModels/EventList.cs create mode 100644 InstarBot/DynamoModels/InstarDatabaseEntry.cs create mode 100644 InstarBot/DynamoModels/InstarUserData.cs create mode 100644 InstarBot/Modals/UserUpdatedEventArgs.cs create mode 100644 InstarBot/Services/IAutoMemberSystem.cs diff --git a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj index f16bfad..a57be9c 100644 --- a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj +++ b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable InstarBot.Tests @@ -10,11 +10,12 @@ - - + + - - + + + diff --git a/InstarBot.Tests.Common/Models/TestGuildUser.cs b/InstarBot.Tests.Common/Models/TestGuildUser.cs index 7e1a21f..708620b 100644 --- a/InstarBot.Tests.Common/Models/TestGuildUser.cs +++ b/InstarBot.Tests.Common/Models/TestGuildUser.cs @@ -185,4 +185,10 @@ public IReadOnlyCollection RoleIds public string AvatarDecorationHash => throw new NotImplementedException(); public ulong? AvatarDecorationSkuId => throw new NotImplementedException(); + public PrimaryGuild? PrimaryGuild { get; } + + public TestGuildUser Clone() + { + return (TestGuildUser) MemberwiseClone(); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs b/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs new file mode 100644 index 0000000..4a6c2f0 --- /dev/null +++ b/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs @@ -0,0 +1,19 @@ +using Discord; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Tests.Services; + +public class MockAutoMemberSystem : IAutoMemberSystem +{ + public Task RunAsync() + { + throw new NotImplementedException(); + } + + public virtual MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IGuildUser user) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockDiscordService.cs b/InstarBot.Tests.Common/Services/MockDiscordService.cs index b1af3a7..da1ca6e 100644 --- a/InstarBot.Tests.Common/Services/MockDiscordService.cs +++ b/InstarBot.Tests.Common/Services/MockDiscordService.cs @@ -2,6 +2,7 @@ using InstarBot.Tests.Models; using JetBrains.Annotations; using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Services; namespace InstarBot.Tests.Services; @@ -10,16 +11,23 @@ public sealed class MockDiscordService : IDiscordService { private readonly IInstarGuild _guild; private readonly AsyncEvent _userJoinedEvent = new(); - private readonly AsyncEvent _messageReceivedEvent = new(); - private readonly AsyncEvent _messageDeletedEvent = new(); + private readonly AsyncEvent _userUpdatedEvent = new(); + private readonly AsyncEvent _messageReceivedEvent = new(); + private readonly AsyncEvent _messageDeletedEvent = new(); - public event Func UserJoined + public event Func UserJoined { add => _userJoinedEvent.Add(value); remove => _userJoinedEvent.Remove(value); - } + } + + public event Func UserUpdated + { + add => _userUpdatedEvent.Add(value); + remove => _userUpdatedEvent.Remove(value); + } - public event Func MessageReceived + public event Func MessageReceived { add => _messageReceivedEvent.Add(value); remove => _messageReceivedEvent.Remove(value); @@ -62,14 +70,19 @@ public async IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime await foreach (var messageList in channel.GetMessagesAsync()) foreach (var message in messageList) yield return message; - } + } - public async Task TriggerUserJoined(IGuildUser user) - { - await _userJoinedEvent.Invoke(user); - } + public async Task TriggerUserJoined(IGuildUser user) + { + await _userJoinedEvent.Invoke(user); + } + + public async Task TriggerUserUpdated(UserUpdatedEventArgs args) + { + await _userUpdatedEvent.Invoke(args); + } - [UsedImplicitly] + [UsedImplicitly] public async Task TriggerMessageReceived(IMessage message) { await _messageReceivedEvent.Invoke(message); diff --git a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs index f9d9676..cb6aecd 100644 --- a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs +++ b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs @@ -1,65 +1,71 @@ -using InstarBot.Tests.Models; +using Amazon.DynamoDBv2.DataModel; +using Discord; +using Moq; using PaxAndromeda.Instar; +using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; namespace InstarBot.Tests.Services; +/// +/// A mock implementation of the IInstarDDBService interface for unit testing purposes. +/// This class provides an in-memory storage mechanism to simulate DynamoDB operations. +/// public sealed class MockInstarDDBService : IInstarDDBService { - private readonly Dictionary _localData; + private readonly Dictionary _localData; public MockInstarDDBService() { - _localData = new Dictionary(); + _localData = new Dictionary(); } - public MockInstarDDBService(IEnumerable preload) + public MockInstarDDBService(IEnumerable preload) { - _localData = preload.ToDictionary(n => n.Snowflake, n => n); + _localData = preload.ToDictionary(n => n.UserID!, n => n); } - public Task UpdateUserBirthday(Snowflake snowflake, DateTime birthday) + public void Register(InstarUserData data) { - _localData.TryAdd(snowflake, new UserDatabaseInformation(snowflake)); - _localData[snowflake].Birthday = birthday; - - return Task.FromResult(true); + _localData.TryAdd(data.UserID!, data); } - public Task UpdateUserJoinDate(Snowflake snowflake, DateTime joinDate) + public Task?> GetUserAsync(Snowflake snowflake) { - _localData.TryAdd(snowflake, new UserDatabaseInformation(snowflake)); - _localData[snowflake].JoinDate = joinDate; + if (!_localData.TryGetValue(snowflake, out var data)) + throw new InvalidOperationException("User not found."); + + var ddbContextMock = new Mock(); - return Task.FromResult(true); + return Task.FromResult(new InstarDatabaseEntry(ddbContextMock.Object, data))!; } - public Task UpdateUserMembership(Snowflake snowflake, bool membershipGranted) + public Task> GetOrCreateUserAsync(IGuildUser user) { - _localData.TryAdd(snowflake, new UserDatabaseInformation(snowflake)); - _localData[snowflake].GrantedMembership = membershipGranted; + if (!_localData.TryGetValue(user.Id, out var data)) + data = InstarUserData.CreateFrom(user); + + var ddbContextMock = new Mock(); - return Task.FromResult(true); + return Task.FromResult(new InstarDatabaseEntry(ddbContextMock.Object, data)); } - public Task GetUserBirthday(Snowflake snowflake) + public Task>> GetBatchUsersAsync(IEnumerable snowflakes) { - return !_localData.TryGetValue(snowflake, out var value) - ? Task.FromResult(null) - : Task.FromResult(value.Birthday); + return Task.FromResult(GetLocalUsers(snowflakes) + .Select(n => new InstarDatabaseEntry(new Mock().Object, n)).ToList()); } - public Task GetUserJoinDate(Snowflake snowflake) + private IEnumerable GetLocalUsers(IEnumerable snowflakes) { - return !_localData.TryGetValue(snowflake, out var value) - ? Task.FromResult(null) - : Task.FromResult(value.JoinDate); + foreach (var snowflake in snowflakes) + if (_localData.TryGetValue(snowflake, out var data)) + yield return data; } - public Task GetUserMembership(Snowflake snowflake) + public Task CreateUserAsync(InstarUserData data) { - return !_localData.TryGetValue(snowflake, out var value) - ? Task.FromResult(null) - : Task.FromResult(value.GrantedMembership); + _localData.TryAdd(data.UserID!, data); + return Task.CompletedTask; } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/TestUtilities.cs b/InstarBot.Tests.Common/TestUtilities.cs index 153ac47..ad3799f 100644 --- a/InstarBot.Tests.Common/TestUtilities.cs +++ b/InstarBot.Tests.Common/TestUtilities.cs @@ -12,6 +12,8 @@ using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.Services; +using Serilog; +using Serilog.Events; namespace InstarBot.Tests; @@ -77,7 +79,7 @@ public static void VerifyMessage(Mock command, string message, bool epheme "RespondAsync", Times.Once(), message, ItExpr.IsAny(), false, ephemeral, ItExpr.IsAny(), ItExpr.IsAny(), - ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } public static IDiscordService SetupDiscordService(TestContext context = null!) @@ -110,7 +112,7 @@ public static Mock SetupCommandMock(Expression> newExpression, Tes public static Mock SetupCommandMock(TestContext context = null!) where T : BaseCommand { - // Quick check: Do we have a constructor that takes IConfiguration? + // Quick check: Do we have a constructor that takes IConfiguration? var iConfigCtor = typeof(T).GetConstructors() .Any(n => n.GetParameters().Any(info => info.ParameterType == typeof(IConfiguration))); @@ -131,7 +133,8 @@ private static void ConfigureCommandMock(Mock mock, TestContext? context) It.IsAny(), ItExpr.IsNull(), ItExpr.IsNull(), ItExpr.IsNull(), ItExpr.IsNull(), - ItExpr.IsNull()) + ItExpr.IsNull(), + ItExpr.IsNull()) .Returns(Task.CompletedTask); } @@ -237,4 +240,14 @@ public static async IAsyncEnumerable GetTeams(PageTarget pageTarget) yield return value; } } + + public static void SetupLogging() + { + Log.Logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.Is(LogEventLevel.Verbose) + .WriteTo.Console() + .CreateLogger(); + Log.Warning("Logging is enabled for this unit test."); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Assembly.cs b/InstarBot.Tests.Integration/Assembly.cs new file mode 100644 index 0000000..4941ec7 --- /dev/null +++ b/InstarBot.Tests.Integration/Assembly.cs @@ -0,0 +1 @@ +[assembly: Parallelize] \ No newline at end of file diff --git a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj index 2041b84..e77e0aa 100644 --- a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj +++ b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj @@ -1,23 +1,23 @@  - net9.0 + net10.0 enable enable - + - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs index 9d282d2..7f493d2 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs @@ -3,7 +3,9 @@ using InstarBot.Tests.Services; using PaxAndromeda.Instar; using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Gaius; +using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Services; using Xunit; @@ -18,7 +20,7 @@ public static class AutoMemberSystemTests private static readonly Snowflake SheHer = new(796578609535647765); private static readonly Snowflake AutoMemberHold = new(966434762032054282); - private static async Task SetupTest(AutoMemberSystemContext scenarioContext) + private static AutoMemberSystem SetupTest(AutoMemberSystemContext scenarioContext) { var testContext = scenarioContext.TestContext; @@ -38,19 +40,22 @@ private static async Task SetupTest(AutoMemberSystemContext sc var amsConfig = scenarioContext.Config.AutoMemberConfig; var ddbService = new MockInstarDDBService(); - if (firstSeenTime > 0) - await ddbService.UpdateUserJoinDate(userId, DateTime.UtcNow - TimeSpan.FromHours(firstSeenTime)); - if (grantedMembershipBefore) - await ddbService.UpdateUserMembership(userId, grantedMembershipBefore); - testContext.AddRoles(roles); + var user = new TestGuildUser + { + Id = userId, + Username = "username", + JoinedAt = DateTimeOffset.Now - TimeSpan.FromHours(relativeJoinTime), + RoleIds = roles.Select(n => n.ID).ToList().AsReadOnly() + }; - var user = new TestGuildUser - { - Id = userId, - JoinedAt = DateTimeOffset.Now - TimeSpan.FromHours(relativeJoinTime), - RoleIds = roles.Select(n => n.ID).ToList().AsReadOnly() - }; + var userData = InstarUserData.CreateFrom(user); + userData.Position = grantedMembershipBefore ? InstarUserPosition.Member : InstarUserPosition.NewMember; + + if (!scenarioContext.SuppressDDBEntry) + ddbService.Register(userData); + + testContext.AddRoles(roles); testContext.GuildUsers.Add(user); @@ -69,6 +74,7 @@ private static async Task SetupTest(AutoMemberSystemContext sc var ams = new AutoMemberSystem(config, discordService, gaiusApiService, ddbService, new MockMetricService()); scenarioContext.User = user; + scenarioContext.DynamoService = ddbService; return ams; } @@ -85,7 +91,7 @@ public static async Task AutoMemberSystem_EligibleUser_ShouldBeGrantedMembership .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -106,7 +112,7 @@ public static async Task AutoMemberSystem_EligibleUserWithAMH_ShouldNotBeGranted .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -127,7 +133,7 @@ public static async Task AutoMemberSystem_NewUser_ShouldNotBeGrantedMembership() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -147,7 +153,7 @@ public static async Task AutoMemberSystem_InactiveUser_ShouldNotBeGrantedMembers .WithMessages(10) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -167,7 +173,7 @@ public static async Task AutoMemberSystem_Member_ShouldNotBeChanged() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -186,7 +192,7 @@ public static async Task AutoMemberSystem_NoIntroduction_ShouldNotBeGrantedMembe .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -206,7 +212,7 @@ public static async Task AutoMemberSystem_NoAgeRole_ShouldNotBeGrantedMembership .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -226,7 +232,7 @@ public static async Task AutoMemberSystem_NoGenderRole_ShouldNotBeGrantedMembers .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -246,7 +252,7 @@ public static async Task AutoMemberSystem_NoPronounRole_ShouldNotBeGrantedMember .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -267,7 +273,7 @@ public static async Task AutoMemberSystem_UserWithGaiusWarning_ShouldNotBeGrante .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -288,7 +294,7 @@ public static async Task AutoMemberSystem_UserWithGaiusCaselog_ShouldNotBeGrante .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -309,7 +315,7 @@ public static async Task AutoMemberSystem_GaiusIsUnavailable_ShouldBeGrantedMemb .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -331,7 +337,7 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe .WithMessages(100) .Build(); - await SetupTest(context); + SetupTest(context); // Act var service = context.DiscordService as MockDiscordService; @@ -346,6 +352,68 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe context.AssertMember(); } + [Fact(DisplayName = "The Dynamo record for a user should be updated when the user's username changes")] + public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflectedInDynamo() + { + // Arrange + const string NewUsername = "fred"; + + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(1)) + .FirstJoined(TimeSpan.FromDays(7)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .HasBeenGrantedMembershipBefore() + .WithMessages(100) + .Build(); + + SetupTest(context); + + // Make sure the user is in the database + context.DynamoService.Should().NotBeNull(because: "Test is invalid if DynamoService is not set"); + await context.DynamoService.CreateUserAsync(InstarUserData.CreateFrom(context.User!)); + + // Act + MockDiscordService mds = (MockDiscordService) context.DiscordService!; + + var newUser = context.User!.Clone(); + newUser.Username = NewUsername; + + await mds.TriggerUserUpdated(new UserUpdatedEventArgs(context.UserID, context.User, newUser)); + + // Assert + var ddbUser = await context.DynamoService.GetUserAsync(context.UserID); + + ddbUser.Should().NotBeNull(); + ddbUser.Data.Username.Should().Be(NewUsername); + + ddbUser.Data.Usernames.Should().NotBeNull(); + ddbUser.Data.Usernames.Count.Should().Be(2); + ddbUser.Data.Usernames.Should().Contain(n => n.Data != null && n.Data.Equals(NewUsername, StringComparison.Ordinal)); + } + + [Fact(DisplayName = "A user should be created in DynamoDB if they're eligible for membership but missing in DDB")] + public static async Task AutoMemberSystem_MemberEligibleButMissingInDDB_ShouldBeCreatedAndGrantedMembership() + { + + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .WithMessages(100) + .SuppressDDBEntry() + .Build(); + + var ams = SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertMember(); + } + private record AutoMemberSystemContext( Snowflake UserID, int HoursSinceJoined, @@ -354,6 +422,7 @@ private record AutoMemberSystemContext( int MessagesLast24Hours, int FirstJoinTime, bool GrantedMembershipBefore, + bool SuppressDDBEntry, TestContext TestContext, InstarDynamicConfiguration Config) { @@ -361,6 +430,7 @@ private record AutoMemberSystemContext( public IDiscordService? DiscordService { get; set; } public TestGuildUser? User { get; set; } + public IInstarDDBService? DynamoService { get ; set ; } public void AssertMember() { @@ -394,8 +464,10 @@ private class AutoMemberSystemContextBuilder private bool _gaiusWarned; private int _firstJoinTime; private bool _grantedMembershipBefore; + private bool _suppressDDB; + - public AutoMemberSystemContextBuilder Joined(TimeSpan timeAgo) + public AutoMemberSystemContextBuilder Joined(TimeSpan timeAgo) { _hoursSinceJoined = (int) Math.Round(timeAgo.TotalHours); return this; @@ -446,9 +518,15 @@ public AutoMemberSystemContextBuilder HasBeenGrantedMembershipBefore() { _grantedMembershipBefore = true; return this; - } + } + + public AutoMemberSystemContextBuilder SuppressDDBEntry() + { + _suppressDDB = true; + return this; + } - public async Task Build() + public async Task Build() { var config = await TestUtilities.GetDynamicConfiguration().GetConfig(); @@ -487,7 +565,8 @@ public async Task Build() _messagesLast24Hours, _firstJoinTime, _grantedMembershipBefore, - testContext, + _suppressDDB, + testContext, config); } } diff --git a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs new file mode 100644 index 0000000..dde6b68 --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs @@ -0,0 +1,169 @@ +using Discord; +using InstarBot.Tests.Services; +using Moq; +using Moq.Protected; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.ConfigModels; +using Xunit; + +namespace InstarBot.Tests.Integration.Interactions; + +public static class CheckEligibilityCommandTests +{ + + private static Mock SetupCommandMock(CheckEligibilityCommandTestContext context) + { + var mockAMS = new Mock(); + mockAMS.Setup(n => n.CheckEligibility(It.IsAny(), It.IsAny())).Returns(context.Eligibility); + + + List userRoles = [ + // from Instar.dynamic.test.debug.conf.json: member and new member role, respectively + context.IsMember ? 793611808372031499ul : 796052052433698817ul + ]; + + if (context.IsAMH) + { + // from Instar.dynamic.test.debug.conf.json + userRoles.Add(966434762032054282); + } + + var commandMock = TestUtilities.SetupCommandMock( + () => new CheckEligibilityCommand(TestUtilities.GetDynamicConfiguration(), mockAMS.Object, new MockMetricService()), + new TestContext + { + UserRoles = userRoles + }); + + return commandMock; + } + + private static void VerifyResponse(Mock command, string expectedString) + { + command.Protected().Verify( + "RespondAsync", + Times.Once(), + expectedString, // text + ItExpr.IsNull(), // embeds + false, // isTTS + true, // ephemeral + ItExpr.IsNull(), // allowedMentions + ItExpr.IsNull(), // options + ItExpr.IsNull(), // components + ItExpr.IsAny(), // embed + ItExpr.IsAny(), // pollProperties + ItExpr.IsAny() // messageFlags + ); + } + + private static void VerifyResponseEmbed(Mock command, CheckEligibilityCommandTestContext ctx) + { + // Little more convoluted to verify embed content + command.Protected().Verify( + "RespondAsync", + Times.Once(), + ItExpr.IsNull(), // text + ItExpr.IsNull(), // embeds + false, // isTTS + true, // ephemeral + ItExpr.IsNull(), // allowedMentions + ItExpr.IsNull(), // options + ItExpr.IsNull(), // components + ItExpr.Is(e => e.Description.Contains(ctx.DescriptionPattern) && e.Description.Contains(ctx.DescriptionPattern2) && + e.Fields.Any(n => n.Value.Contains(ctx.MissingItemPattern)) + ), // embed + ItExpr.IsNull(), // pollProperties + ItExpr.IsAny() // messageFlags + ); + } + + [Fact] + public static async Task CheckEligibilityCommand_WithExistingMember_ShouldEmitValidMessage() + { + // Arrange + var ctx = new CheckEligibilityCommandTestContext(false, true, MembershipEligibility.Eligible); + var mock = SetupCommandMock(ctx); + + // Act + await mock.Object.CheckEligibility(); + + // Assert + VerifyResponse(mock, "You are already a member!"); + } + + [Fact] + public static async Task CheckEligibilityCommand_WithNewMemberAMH_ShouldEmitValidMessage() + { + // Arrange + var ctx = new CheckEligibilityCommandTestContext( + true, + false, + MembershipEligibility.Eligible, + "Your membership is currently on hold", + MissingItemPattern: "The staff will override an administrative hold"); + + var mock = SetupCommandMock(ctx); + + // Act + await mock.Object.CheckEligibility(); + + // Assert + VerifyResponseEmbed(mock, ctx); + } + + [Theory] + [InlineData(MembershipEligibility.MissingRoles, "You are missing an age role.")] + [InlineData(MembershipEligibility.MissingIntroduction, "You have not posted an introduction in")] + [InlineData(MembershipEligibility.TooYoung, "You have not been on the server for")] + [InlineData(MembershipEligibility.PunishmentReceived, "You have received a warning or moderator action.")] + [InlineData(MembershipEligibility.NotEnoughMessages, "messages in the past")] + public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMessage(MembershipEligibility eligibility, string pattern) + { + // Arrange + string sectionHeader = eligibility switch + { + MembershipEligibility.MissingRoles => "Roles", + MembershipEligibility.MissingIntroduction => "Introduction", + MembershipEligibility.TooYoung => "Join Age", + MembershipEligibility.PunishmentReceived => "Mod Actions", + MembershipEligibility.NotEnoughMessages => "Messages", + _ => "" + }; + + // Cheeky way to get another section header + string anotherSectionHeader = (MembershipEligibility) ((int)eligibility << 1) switch + { + MembershipEligibility.MissingRoles => "Roles", + MembershipEligibility.MissingIntroduction => "Introduction", + MembershipEligibility.TooYoung => "Join Age", + MembershipEligibility.PunishmentReceived => "Mod Actions", + MembershipEligibility.NotEnoughMessages => "Messages", + _ => "Roles" + }; + + var ctx = new CheckEligibilityCommandTestContext( + false, + false, + MembershipEligibility.NotEligible | eligibility, + $":x: **{sectionHeader}**", + $":white_check_mark: **{anotherSectionHeader}", + pattern); + + var mock = SetupCommandMock(ctx); + + // Act + await mock.Object.CheckEligibility(); + + // Assert + VerifyResponseEmbed(mock, ctx); + } + + private record CheckEligibilityCommandTestContext( + bool IsAMH, + bool IsMember, + MembershipEligibility Eligibility, + string DescriptionPattern = "", + string DescriptionPattern2 = "", + string MissingItemPattern = ""); +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index 8ca2607..dbdb3b4 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -68,7 +68,7 @@ await TestUtilities.GetTeams(PageTarget.All).Select(n => Snowflake.GetMention(() "RespondAsync", Times.Once(), expectedString, ItExpr.IsNull(), false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); + ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } [Fact(DisplayName = "User should be able to page when authorized")] diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs index 53d3acd..eed8d1a 100644 --- a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -39,7 +39,7 @@ await command.Object.ModalResponse(new ReportMessageModal It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())); - Assert.NotNull(context.ResultEmbed); + context.ResultEmbed.Should().NotBeNull(); var embed = context.ResultEmbed; embed.Author.Should().NotBeNull(); diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs index 4812d95..f97203a 100644 --- a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -4,6 +4,7 @@ using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; using Xunit; @@ -13,13 +14,17 @@ public static class SetBirthdayCommandTests { private static (IInstarDDBService, Mock) SetupMocks(SetBirthdayContext context) { + TestUtilities.SetupLogging(); + var ddbService = TestUtilities.GetServices().GetService(); var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService!, new MockMetricService()), new TestContext { UserID = context.User.ID }); + + ((MockInstarDDBService) ddbService!).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); - Assert.NotNull(ddbService); + ddbService.Should().NotBeNull(); return (ddbService, cmd); } @@ -44,7 +49,9 @@ public static async Task SetBirthdayCommand_WithValidDate_ShouldSetCorrectly(int // Assert var date = context.ToDateTime(); - (await ddb.GetUserBirthday(context.User.ID)).Should().Be(date.UtcDateTime); + + var ddbUser = await ddb.GetUserAsync(context.User.ID); + ddbUser!.Data.Birthday.Should().Be(date.UtcDateTime); TestUtilities.VerifyMessage(cmd, $"Your birthday was set to {date.DateTime:dddd, MMMM d, yyy}.", true); } diff --git a/InstarBot.Tests.Unit/Assembly.cs b/InstarBot.Tests.Unit/Assembly.cs new file mode 100644 index 0000000..5d68f78 --- /dev/null +++ b/InstarBot.Tests.Unit/Assembly.cs @@ -0,0 +1,3 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[assembly: Parallelize] \ No newline at end of file diff --git a/InstarBot.Tests.Unit/DynamoModels/EventListTests.cs b/InstarBot.Tests.Unit/DynamoModels/EventListTests.cs new file mode 100644 index 0000000..f041a8c --- /dev/null +++ b/InstarBot.Tests.Unit/DynamoModels/EventListTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using PaxAndromeda.Instar.DynamoModels; +using Serilog; +using Xunit; + +namespace InstarBot.Tests.DynamoModels; + +public class EventListTests +{ + [Fact] + public void Add_SequentialItems_ShouldBeAddedInOrder() + { + TestUtilities.SetupLogging(); + + // Arrange/Act + //var list = new EventList>(); + var list = new SortedSet>(Comparer>.Create((x, y) => x.Date.CompareTo(y.Date))) + { + new(1, DateTime.Now - TimeSpan.FromMinutes(2)), + new(2, DateTime.Now - TimeSpan.FromMinutes(1)), + new(3, DateTime.Now) + }; + + // Assert + var collapsedList = list.ToList(); + collapsedList[0].Value.Should().Be(1); + collapsedList[1].Value.Should().Be(2); + collapsedList[2].Value.Should().Be(3); + } + + [Fact] + public void Add_IntermediateItem_ShouldBeAddedInMiddle() + { + TestUtilities.SetupLogging(); + + // Arrange + var list = new EventList> + { + new(1, DateTime.Now - TimeSpan.FromMinutes(2)), + new(3, DateTime.Now) + }; + + list.First().Value.Should().Be(1); + + // Act + Log.Information("Inserting entry 2 at the middle of the list."); + list.Add(new TestEntry(2, DateTime.Now - TimeSpan.FromMinutes(1))); + + // Assert + var collapsedList = list.ToList(); + collapsedList[0].Value.Should().Be(1); + collapsedList[1].Value.Should().Be(2); // this should be in the middle + collapsedList[2].Value.Should().Be(3); + } + + [Fact] + public void Add_LastItem_ShouldBeAddedInMiddle() + { + TestUtilities.SetupLogging(); + + // Arrange + var list = new EventList> + { + new(3, DateTime.Now), + new(2, DateTime.Now - TimeSpan.FromMinutes(1)) + }; + + list.Latest().Value.Should().Be(3); + list.First().Value.Should().Be(2); + + // Act + Log.Information("Inserting entry 1 at the end of the list."); + list.Add(new TestEntry(1, DateTime.Now - TimeSpan.FromMinutes(2))); + + // Assert + var collapsedList = list.ToList(); + collapsedList[0].Value.Should().Be(1); + collapsedList[1].Value.Should().Be(2); // this should be in the middle + collapsedList[2].Value.Should().Be(3); + } + + [Fact] + public void Add_RandomItems_ShouldBeChronological() + { + TestUtilities.SetupLogging(); + + // Arrange + var items = new List>(); + for (var i = 0; i < 100; i++) + items.Add(new TestEntry(i, DateTime.Now - TimeSpan.FromMinutes(i))); + + // Run a Fisher-Yates shuffle: + var rng = new Random(); + var n = items.Count; + while (n > 1) + { + n--; + var k = rng.Next(n + 1); + (items[k], items[n]) = (items[n], items[k]); + } + + // Arrange + var list = new EventList>(items); + + // Act + Log.Information("Inserting entry 1 at the end of the list."); + list.Add(new TestEntry(1, DateTime.Now - TimeSpan.FromMinutes(2))); + + // Assert + var collapsedList = list.ToList(); + + collapsedList.Should().BeInDescendingOrder(d => d.Value); + } + + private class TestEntry(T value, DateTime date) : ITimedEvent + { + public DateTime Date { get; set; } = date; + public T Value { get; set; } = value; + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj index dd7af44..73c5914 100644 --- a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj +++ b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable false @@ -11,13 +11,13 @@ - - + + - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/InstarBot.Tests.Unit/UtilitiesTests.cs b/InstarBot.Tests.Unit/UtilitiesTests.cs new file mode 100644 index 0000000..d15c70c --- /dev/null +++ b/InstarBot.Tests.Unit/UtilitiesTests.cs @@ -0,0 +1,22 @@ +using FluentAssertions; +using PaxAndromeda.Instar; +using Xunit; + +namespace InstarBot.Tests; + +public class UtilitiesTests +{ + [Theory] + [InlineData("OWNER", "Owner")] + [InlineData("ADMIN", "Admin")] + [InlineData("MODERATOR", "Moderator")] + [InlineData("SENIOR_HELPER", "SeniorHelper")] + [InlineData("HELPER", "Helper")] + [InlineData("COMMUNITY_MANAGER", "CommunityManager")] + [InlineData("MEMBER", "Member")] + [InlineData("NEW_MEMBER", "NewMember")] + public void ScreamingToSnakeCase_ShouldProduceValidSnakeCase(string input, string expected) + { + Utilities.ScreamingToPascalCase(input).Should().Be(expected); + } +} \ No newline at end of file diff --git a/InstarBot/BadStateException.cs b/InstarBot/BadStateException.cs new file mode 100644 index 0000000..903b5cf --- /dev/null +++ b/InstarBot/BadStateException.cs @@ -0,0 +1,24 @@ +namespace PaxAndromeda.Instar; + +/// +/// Represents an exception that is thrown when an operation encounters an invalid or unexpected state. +/// +/// +/// Use this exception to indicate that a method or process cannot proceed due to the current state of +/// the object or system. This exception is typically thrown when a precondition for an operation is not met, and +/// recovery may require correcting the state before retrying. +/// +public class BadStateException : Exception +{ + public BadStateException() + { + } + + public BadStateException(string message) : base(message) + { + } + + public BadStateException(string message, Exception inner) : base(message, inner) + { + } +} \ No newline at end of file diff --git a/InstarBot/Commands/AutoMemberHoldCommand.cs b/InstarBot/Commands/AutoMemberHoldCommand.cs new file mode 100644 index 0000000..5cc94e6 --- /dev/null +++ b/InstarBot/Commands/AutoMemberHoldCommand.cs @@ -0,0 +1,121 @@ +using System.Diagnostics.CodeAnalysis; +using Ardalis.GuardClauses; +using Discord; +using Discord.Interactions; +using JetBrains.Annotations; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Metrics; +using PaxAndromeda.Instar.Services; +using Serilog; + +namespace PaxAndromeda.Instar.Commands; + +[SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] +public class AutoMemberHoldCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfigService, IMetricService metricService) : BaseCommand +{ + [UsedImplicitly] + [SlashCommand("amh", "Withhold automatic membership grants to a user.")] + public async Task HoldMember( + [Summary("user", "The user to withhold automatic membership from.")] + IUser user, + [Summary("reason", "The reason for withholding automatic membership.")] + string reason + ) + { + Guard.Against.NullOrEmpty(reason); + Guard.Against.Null(Context.User); + Guard.Against.Null(user); + + var date = DateTime.UtcNow; + Snowflake modId = Context.User.Id; + + try + { + if (user is not IGuildUser guildUser) + { + await RespondAsync($"Error while attempting to withhold membership for <@{user.Id}>: User is not a guild member.", ephemeral: true); + return; + } + + var config = await dynamicConfigService.GetConfig(); + + if (guildUser.RoleIds.Contains(config.MemberRoleID)) + { + await RespondAsync($"Error while attempting to withhold membership for <@{user.Id}>: User is already a member.", ephemeral: true); + return; + } + + var dbUser = await ddbService.GetOrCreateUserAsync(guildUser); + + dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + ModeratorID = modId, + Reason = reason, + Date = date + }; + await dbUser.UpdateAsync(); + + // TODO: configurable duration? + await RespondAsync($"Membership for user <@{user.Id}> has been withheld. Staff will be notified in one week to review.", ephemeral: true); + } catch (Exception ex) + { + await metricService.Emit(Metric.AMS_AMHFailures, 1); + Log.Error(ex, "Failed to apply auto member hold requested by {ModID} to {UserID} for reason: \"{Reason}\"", modId.ID, user.Id, reason); + + try + { + // It is entirely possible that RespondAsync threw this error. + await RespondAsync($"Error while attempting to withhold membership for <@{user.Id}>: An unexpected error has occurred while configuring the AMH.", ephemeral: true); + } catch + { + // swallow the exception + } + } + } + + [UsedImplicitly] + [SlashCommand("removeamh", "Removes an auto member hold from the user.")] + public async Task UnholdMember( + [Summary("user", "The user to remove the auto member hold from.")] + IUser user + ) + { + Guard.Against.Null(Context.User); + Guard.Against.Null(user); + + if (user is not IGuildUser guildUser) + { + await RespondAsync($"Error while attempting to remove auto member hold for <@{user.Id}>: User is not a guild member.", ephemeral: true); + return; + } + + try + { + var dbUser = await ddbService.GetOrCreateUserAsync(guildUser); + if (dbUser.Data.AutoMemberHoldRecord is null) + { + await RespondAsync($"Error while attempting to remove auto member hold for <@{user.Id}>: User does not have an active auto member hold.", ephemeral: true); + return; + } + + dbUser.Data.AutoMemberHoldRecord = null; + await dbUser.UpdateAsync(); + + await RespondAsync($"Auto member hold for user <@{user.Id}> has been removed.", ephemeral: true); + } + catch (Exception ex) + { + await metricService.Emit(Metric.AMS_AMHFailures, 1); + Log.Error(ex, "Failed to remove auto member hold requested by {ModID} from {UserID}", Context.User.Id, user.Id); + + try + { + await RespondAsync($"Error while attempting to remove auto member hold for <@{user.Id}>: An unexpected error has occurred while removing the AMH.", ephemeral: true); + } + catch + { + // swallow the exception + } + } + } +} \ No newline at end of file diff --git a/InstarBot/Commands/CheckEligibilityCommand.cs b/InstarBot/Commands/CheckEligibilityCommand.cs index 4127f0d..2caac84 100644 --- a/InstarBot/Commands/CheckEligibilityCommand.cs +++ b/InstarBot/Commands/CheckEligibilityCommand.cs @@ -3,6 +3,7 @@ using Discord; using Discord.Interactions; using JetBrains.Annotations; +using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Serilog; @@ -12,7 +13,8 @@ namespace PaxAndromeda.Instar.Commands; [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] public class CheckEligibilityCommand( IDynamicConfigService dynamicConfig, - AutoMemberSystem autoMemberSystem, + IAutoMemberSystem autoMemberSystem, + IInstarDDBService ddbService, IMetricService metricService) : BaseCommand { @@ -39,43 +41,157 @@ public async Task CheckEligibility() await RespondAsync("You are already a member!", ephemeral: true); return; } - - var eligibility = await autoMemberSystem.CheckEligibility(Context.User); - - Log.Debug("Building response embed..."); - var fields = new List(); - if (eligibility.HasFlag(MembershipEligibility.NotEligible)) - { - fields.Add(new EmbedFieldBuilder() - .WithName("Missing Items") - .WithValue(await BuildMissingItemsText(eligibility, Context.User))); - } - var nextRun = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, - DateTimeOffset.UtcNow.Day, DateTimeOffset.UtcNow.Hour, 0, 0, TimeSpan.Zero) - + TimeSpan.FromHours(1); - var unixTime = nextRun.UtcTicks / 10000000-62135596800; // UTC ticks since year 0 to Unix Timestamp - - fields.Add(new EmbedFieldBuilder() - .WithName("Note") - .WithValue($"The Auto Member System will run . Membership eligibility is subject to change at the time of evaluation.")); + if (Context.User!.RoleIds.Contains(config.AutoMemberConfig.HoldRole)) + { + // User is on hold + await RespondAsync(embed: BuildAMHEmbed(), ephemeral: true); + return; + } - var builder = new EmbedBuilder() - // Set up all the basic stuff first - .WithCurrentTimestamp() - .WithColor(0x0c94e0) - .WithFooter(new EmbedFooterBuilder() - .WithText("Instar Auto Member System System") - .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) - .WithTitle("Membership Eligibility") - .WithDescription(BuildEligibilityText(eligibility)) - .WithFields(fields); + var embed = await BuildEligibilityEmbed(config, Context.User); Log.Debug("Responding..."); - await RespondAsync(embed: builder.Build(), ephemeral: true); + await RespondAsync(embed: embed, ephemeral: true); await metricService.Emit(Metric.AMS_EligibilityCheck, 1); } + [UsedImplicitly] + [SlashCommand("eligibility", "Checks the eligibility of another user on the server.")] + public async Task CheckOtherEligibility(IUser user) + { + if (user is not IGuildUser guildUser) + { + await RespondAsync($"Cannot check the eligibility for {user.Id} since they are not on this server.", ephemeral: true); + return; + } + + var cfg = await dynamicConfig.GetConfig(); + + var eligibility = autoMemberSystem.CheckEligibility(cfg, guildUser); + + // Let's build a fancy embed + var fields = new List(); + + bool hasAMH = false; + try + { + var dbUser = await ddbService.GetOrCreateUserAsync(guildUser); + if (dbUser.Data.AutoMemberHoldRecord is not null) + { + StringBuilder amhContextBuilder = new(); + amhContextBuilder.AppendLine($"**Mod:** <@{dbUser.Data.AutoMemberHoldRecord.ModeratorID.ID}>"); + amhContextBuilder.AppendLine("**Reason:**"); + amhContextBuilder.AppendLine($"```{dbUser.Data.AutoMemberHoldRecord.Reason}```"); + + amhContextBuilder.Append("**Date:** "); + + var secondsSinceEpoch = (long) Math.Floor((dbUser.Data.AutoMemberHoldRecord.Date - DateTime.UnixEpoch).TotalSeconds); + amhContextBuilder.Append($" ()"); + + fields.Add(new EmbedFieldBuilder() + .WithName(":warning: Auto Member Hold") + .WithValue(amhContextBuilder.ToString())); + hasAMH = true; + } + } catch (Exception ex) + { + Log.Error(ex, "Failed to retrieve user from DynamoDB while checking eligibility: {UserID}", user.Id); + + // Since we can't give exact details, we'll just note that there was an error + // and just confirm that the member's AMH status is unknown. + fields.Add(new EmbedFieldBuilder() + .WithName(":warning: Possible Auto Member Hold") + .WithValue("Instar encountered an error while attempting to retrieve AMH details. If the user is otherwise eligible but is not being granted membership, it is possible that they are AMHed. Please try again later.")); + } + + // Only add eligibility requirements if the user is not AMHed + if (!hasAMH) + { + fields.Add(new EmbedFieldBuilder() + .WithName(":small_blue_diamond: Requirements") + .WithValue(BuildEligibilityText(eligibility))); + } + + var builder = new EmbedBuilder() + .WithCurrentTimestamp() + .WithTitle("Membership Eligibility") + .WithFooter(new EmbedFooterBuilder() + .WithText("Instar Auto Member System") + .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) + .WithAuthor(new EmbedAuthorBuilder() + .WithName(user.Username) + .WithIconUrl(user.GetAvatarUrl())) + .WithDescription($"At this time, <@{user.Id}> is " + (!eligibility.HasFlag(MembershipEligibility.Eligible) ? "__not__ " : "") + " eligible for membership.") + .WithFields(fields); + + await RespondAsync(embed: builder.Build(), ephemeral: true); + } + + private static Embed BuildAMHEmbed() + { + var fields = new List + { + new EmbedFieldBuilder() + .WithName("Why?") + .WithValue("Your activity has been flagged as unusual by our system. This can happen for a variety of reasons, including antisocial behavior, repeated rule violations in a short period of time, or other behavior that is deemed disruptive."), + new EmbedFieldBuilder() + .WithName("What can I do?") + .WithValue("The staff will override an administrative hold in time when an evaluation of your behavior has been completed by the staff."), + new EmbedFieldBuilder() + .WithName("Should I contact Staff?") + .WithValue("No, the staff will not accelerate this process by request.") + }; + + var builder = new EmbedBuilder() + // Set up all the basic stuff first + .WithCurrentTimestamp() + .WithFooter(new EmbedFooterBuilder() + .WithText("Instar Auto Member System") + .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) + .WithTitle("Membership Eligibility") + .WithDescription("Your membership is currently on hold due to an administrative override. This means that you will not be granted the Member role automatically.") + .WithFields(fields); + + return builder.Build(); + } + + private async Task BuildEligibilityEmbed(InstarDynamicConfiguration config, IGuildUser user) + { + var eligibility = autoMemberSystem.CheckEligibility(config, user); + + Log.Debug("Building response embed..."); + var fields = new List(); + if (eligibility.HasFlag(MembershipEligibility.NotEligible)) + { + fields.Add(new EmbedFieldBuilder() + .WithName("Missing Items") + .WithValue(await BuildMissingItemsText(eligibility, user))); + } + + var nextRun = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, + DateTimeOffset.UtcNow.Day, DateTimeOffset.UtcNow.Hour, 0, 0, TimeSpan.Zero) + + TimeSpan.FromHours(1); + var unixTime = nextRun.UtcTicks / 10000000-62135596800; // UTC ticks since year 0 to Unix Timestamp + + fields.Add(new EmbedFieldBuilder() + .WithName("Note") + .WithValue($"The Auto Member System will run . Membership eligibility is subject to change at the time of evaluation.")); + + var builder = new EmbedBuilder() + // Set up all the basic stuff first + .WithCurrentTimestamp() + .WithColor(0x0c94e0) + .WithFooter(new EmbedFooterBuilder() + .WithText("Instar Auto Member System") + .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) + .WithTitle("Membership Eligibility") + .WithDescription(BuildEligibilityText(eligibility)) + .WithFields(fields); + + return builder.Build(); + } + private async Task BuildMissingItemsText(MembershipEligibility eligibility, IGuildUser user) { var config = await dynamicConfig.GetConfig(); @@ -113,30 +229,30 @@ private async Task BuildMissingItemsText(MembershipEligibility eligibili return missingItemsBuilder.ToString(); } - private static string BuildEligibilityText(MembershipEligibility eligibility) - { - var eligibilityBuilder = new StringBuilder(); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.MissingRoles) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Roles**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.MissingIntroduction) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Introduction**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.TooYoung) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Join Age**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.PunishmentReceived) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Mod Actions**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.NotEnoughMessages) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Messages** (last 24 hours)"); - - return eligibilityBuilder.ToString(); - } + private static string BuildEligibilityText(MembershipEligibility eligibility) + { + var eligibilityBuilder = new StringBuilder(); + eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.MissingRoles) + ? ":x:" + : ":white_check_mark:"); + eligibilityBuilder.AppendLine(" **Roles**"); + eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.MissingIntroduction) + ? ":x:" + : ":white_check_mark:"); + eligibilityBuilder.AppendLine(" **Introduction**"); + eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.TooYoung) + ? ":x:" + : ":white_check_mark:"); + eligibilityBuilder.AppendLine(" **Join Age**"); + eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.PunishmentReceived) + ? ":x:" + : ":white_check_mark:"); + eligibilityBuilder.AppendLine(" **Mod Actions**"); + eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.NotEnoughMessages) + ? ":x:" + : ":white_check_mark:"); + eligibilityBuilder.AppendLine(" **Messages** (last 24 hours)"); + + return eligibilityBuilder.ToString(); + } } \ No newline at end of file diff --git a/InstarBot/Commands/SetBirthdayCommand.cs b/InstarBot/Commands/SetBirthdayCommand.cs index 074ec21..677e619 100644 --- a/InstarBot/Commands/SetBirthdayCommand.cs +++ b/InstarBot/Commands/SetBirthdayCommand.cs @@ -14,9 +14,7 @@ namespace PaxAndromeda.Instar.Commands; public class SetBirthdayCommand(IInstarDDBService ddbService, IMetricService metricService) : BaseCommand { [UsedImplicitly] - [RequireOwner] - [DefaultMemberPermissions(GuildPermission.Administrator)] - [SlashCommand("setbirthday", "Sets your birthday on the server.")] + [SlashCommand("setbirthday", "Sets your birthday on the server.")] public async Task SetBirthday( [MinValue(1)] [MaxValue(12)] [Summary(description: "The month you were born.")] Month month, @@ -30,6 +28,15 @@ public async Task SetBirthday( [Autocomplete] int tzOffset = 0) { + if (Context.User is null) + { + Log.Warning("Context.User was null"); + await RespondAsync( + "An unknown error has occurred. Instar developers have been notified.", + ephemeral: true); + return; + } + if ((int)month is < 0 or > 12) { await RespondAsync( @@ -71,10 +78,13 @@ await RespondAsync( dtUtc); // TODO: Notify staff? - var ok = await ddbService.UpdateUserBirthday(new Snowflake(Context.User!.Id), dtUtc); - - if (ok) + try { + var dbUser = await ddbService.GetOrCreateUserAsync(Context.User); + dbUser.Data.Birthday = dtUtc; + await dbUser.UpdateAsync(); + + Log.Information("User {UserID} birthday set to {DateTime} (UTC time calculated as {UtcTime})", Context.User!.Id, dtLocal, dtUtc); @@ -82,9 +92,9 @@ await RespondAsync( await RespondAsync($"Your birthday was set to {dtLocal:D}.", ephemeral: true); await metricService.Emit(Metric.BS_BirthdaysSet, 1); } - else + catch (Exception ex) { - Log.Warning("Failed to update {UserID}'s birthday due to a DynamoDB failure", + Log.Error(ex, "Failed to update {UserID}'s birthday due to a DynamoDB failure", Context.User!.Id); await RespondAsync("Your birthday could not be set at this time. Please try again later.", diff --git a/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs b/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs index 2adac4c..2dfbcc7 100644 --- a/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs +++ b/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs @@ -5,7 +5,7 @@ namespace PaxAndromeda.Instar.Commands; -public sealed class TriggerAutoMemberSystemCommand(AutoMemberSystem ams) : BaseCommand +public sealed class TriggerAutoMemberSystemCommand(IAutoMemberSystem ams) : BaseCommand { [UsedImplicitly] [RequireOwner] diff --git a/InstarBot/Config/Instar.conf.schema.json b/InstarBot/Config/Instar.conf.schema.json index f4a9b06..8038ac5 100644 --- a/InstarBot/Config/Instar.conf.schema.json +++ b/InstarBot/Config/Instar.conf.schema.json @@ -44,6 +44,20 @@ "type": "string" } } + }, + "AppConfig": { + "type": "object", + "properties": { + "Application": { + "type": "string" + }, + "Environment": { + "type": "string" + }, + "ConfigurationProfile": { + "type": "string" + } + } } }, "required": [ diff --git a/InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs b/InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs new file mode 100644 index 0000000..92942b6 --- /dev/null +++ b/InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs @@ -0,0 +1,243 @@ +using System.Collections; +using System.Reflection; +using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; + +namespace PaxAndromeda.Instar.DynamoModels; + +public class ArbitraryDynamoDBTypeConverter: IPropertyConverter where T: new() +{ + public DynamoDBEntry ToEntry(object value) + { + /* + * Object to DynamoDB Entry + * + * For this conversion, we will only look for public properties. + * We will apply a 1:1 conversion between property names and DynamoDB properties. + * We will also apply this recursively as needed (with a max definable depth) to + * prevent loop conditions. + * + * A few property attributes we need to be aware of are DynamoDBProperty and its + * derivatives, and DynamoDBIgnore. We can ignore any property with + * a DynamoDBIgnore attribute. + * + * Any attribute that inherits from DynamoDBProperty will be handled using special + * logic: The property name can be substituted by the attribute's `AttributeName` + * property (if it exists), and a special converter may be used as well (if it exists). + * + * We will do no special handling for epoch conversions. If no converter is defined, + * we will either handle it natively if it is a primitive type or otherwise known + * to DynamoDBv2 SDK, otherwise we'll just apply this arbitrary type converter. + */ + + return ToDynamoDBEntry(value); + } + + public object FromEntry(DynamoDBEntry entry) + { + return FromDynamoDBEntry(entry.AsDocument()); + } + + private static Document ToDynamoDBEntry(object obj, int maxDepth = 3, int currentDepth = 0) + { + ArgumentNullException.ThrowIfNull(obj); + + if (currentDepth > maxDepth) + throw new InvalidOperationException("Max recursion depth reached"); + + var doc = new Document(); + + // Loop through all public properties of the object + foreach (var property in obj.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + // Ignore write-only or non-readable properties + if (!property.CanRead) continue; + + // Handle properties marked with [DynamoDBIgnore] + if (Attribute.IsDefined(property, typeof(DynamoDBIgnoreAttribute))) + continue; + + var propertyName = property.Name; + + // Check if a DynamoDBProperty is defined on the property + if (Attribute.IsDefined(property, typeof(DynamoDBPropertyAttribute))) + { + var dynamoDBProperty = property.GetCustomAttribute(); + if (!string.IsNullOrEmpty(dynamoDBProperty?.AttributeName)) + { + propertyName = dynamoDBProperty.AttributeName; + } + } + + var propertyValue = property.GetValue(obj); + if (propertyValue == null) continue; + + // Check for converters + var converterAttr = property.GetCustomAttribute()?.Converter; + if (converterAttr != null && Activator.CreateInstance(converterAttr) is IPropertyConverter converter) + { + doc[propertyName] = converter.ToEntry(propertyValue); + } + else + { + // Perform recursive or native handling + doc[propertyName] = ConvertToDynamoDBValue(propertyValue, maxDepth, currentDepth + 1); + } + } + + return doc; + } + + private static DynamoDBEntry ConvertToDynamoDBValue(object value, int maxDepth, int currentDepth) + { + if (value == null) return new Primitive(); + + // Handle primitive types natively supported by DynamoDB + if (value is string || value is bool || value is int || value is long || value is short || value is double || value is float || value is decimal) + { + return new Primitive + { + Value = value + }; + } + + // Handle DateTime + if (value is DateTime dateTimeVal) + { + return new Primitive + { + Value = dateTimeVal.ToString("o") + }; + } + + // Handle collections (e.g., arrays, lists) + if (value is IEnumerable enumerable) + { + var list = new DynamoDBList(); + foreach (var element in enumerable) + { + list.Add(ConvertToDynamoDBValue(element, maxDepth, currentDepth)); + } + return list; + } + + // Handle objects recursively + if (value.GetType().IsClass) + { + return ToDynamoDBEntry(value, maxDepth, currentDepth); + } + + throw new InvalidOperationException($"Cannot convert type {value.GetType()} to DynamoDBEntry."); + } + + public static T FromDynamoDBEntry(Document document, int maxDepth = 3, int currentDepth = 0) where T : new() + { + if (document == null) + throw new ArgumentNullException(nameof(document)); + + if (currentDepth > maxDepth) + throw new InvalidOperationException("Max recursion depth reached."); + + var obj = new T(); + + foreach (var property in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + // Ignore write-only or non-readable properties + if (!property.CanWrite) continue; + + // Skip properties marked with [DynamoDBIgnore] + if (Attribute.IsDefined(property, typeof(DynamoDBIgnoreAttribute))) + continue; + + var propertyName = property.Name; + + // Look for [DynamoDBProperty] to handle custom mappings + if (Attribute.IsDefined(property, typeof(DynamoDBPropertyAttribute))) + { + var dynamoDBProperty = property.GetCustomAttribute(); + if (!string.IsNullOrWhiteSpace(dynamoDBProperty?.AttributeName)) + propertyName = dynamoDBProperty.AttributeName; + } + + // Check if the document contains the property + if (!document.ContainsKey(propertyName)) continue; + + var entry = document[propertyName]; + var converterAttr = property.GetCustomAttribute()?.Converter; + + if (converterAttr != null && Activator.CreateInstance(converterAttr) is IPropertyConverter converter) + { + // Use the custom converter to deserialize + property.SetValue(obj, converter.FromEntry(entry)); + } + else + { + // Perform recursive or default conversion + var convertedValue = FromDynamoDBValue(property.PropertyType, entry, maxDepth, currentDepth + 1); + property.SetValue(obj, convertedValue); + } + } + + return obj; + } + + private static object? FromDynamoDBValue(Type targetType, DynamoDBEntry entry, int maxDepth, int currentDepth) + { + if (entry is Primitive primitive) + { + // Handle primitive types + if (targetType == typeof(string)) return primitive.AsString(); + if (targetType == typeof(bool)) return primitive.AsBoolean(); + if (targetType == typeof(int)) return primitive.AsInt(); + if (targetType == typeof(long)) return primitive.AsLong(); + if (targetType == typeof(short)) return primitive.AsShort(); + if (targetType == typeof(double)) return primitive.AsDouble(); + if (targetType == typeof(float)) return primitive.AsSingle(); + if (targetType == typeof(decimal)) return Convert.ToDecimal(primitive.Value); + if (targetType == typeof(DateTime)) return DateTime.Parse(primitive.AsString()); + + throw new InvalidOperationException($"Unhandled primitive type conversion: {targetType}"); + } + + if (entry is DynamoDBList list) + { + if (typeof(IEnumerable).IsAssignableFrom(targetType)) + { + var elementType = targetType.IsArray + ? targetType.GetElementType() + : targetType.GetGenericArguments().FirstOrDefault(); + if (elementType == null) + throw new InvalidOperationException($"Cannot determine element type for target type: {targetType}"); + + var enumerableType = typeof(List<>).MakeGenericType(elementType); + var resultList = (IList)Activator.CreateInstance(enumerableType); + foreach (var element in list.Entries) + { + resultList.Add(FromDynamoDBValue(elementType, element, maxDepth, currentDepth)); + } + + return targetType.IsArray ? Activator.CreateInstance(targetType, resultList) : resultList; + } + } + + if (entry is Document document) + { + if (targetType.IsClass) + { + // Recurse for nested objects + var fromEntryMethod = typeof(ArbitraryDynamoDBTypeConverter).GetMethod(nameof(FromDynamoDBEntry), + BindingFlags.Public | BindingFlags.Static) + ?.MakeGenericMethod(targetType); + if (fromEntryMethod == null) + throw new InvalidOperationException( + $"Unable to deserialize nested type: {targetType}"); + + return fromEntryMethod.Invoke(null, [ document, maxDepth, currentDepth ]); + } + } + + // Unsupported or unknown type + throw new InvalidOperationException($"Cannot convert DynamoDB entry to type: {targetType}"); + } + +} \ No newline at end of file diff --git a/InstarBot/DynamoModels/EventList.cs b/InstarBot/DynamoModels/EventList.cs new file mode 100644 index 0000000..57f3a05 --- /dev/null +++ b/InstarBot/DynamoModels/EventList.cs @@ -0,0 +1,72 @@ +using System.Collections; +using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; +using Newtonsoft.Json; + +namespace PaxAndromeda.Instar.DynamoModels; + +/// +/// Represents a chronological sequence of events of type . +/// +/// The type of the event, which must implement . +public class EventList : IEnumerable + where T : ITimedEvent +{ + private readonly SortedSet _backbone; + + /// + /// Creates a new . + /// + public EventList() + { + _backbone = new SortedSet(Comparer.Create((x, y) => x.Date.CompareTo(y.Date))); + } + + /// + /// Creates a new from a set of events. + /// + /// The type of the events, which must implement . + public EventList(IEnumerable events) : this() + { + foreach (var item in events) + Add(item); + } + + public T? Latest() => _backbone.Max; + + /// + /// Inserts an item into the sequence while maintaining chronological order. + /// + /// The item to be inserted into the sequence. + public void Add(T item) + => _backbone.Add(item); + + public IEnumerator GetEnumerator() => _backbone.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +public class EventListPropertyConverter : IPropertyConverter where T: ITimedEvent +{ + public DynamoDBEntry ToEntry(object value) + { + if (value is not IEnumerable enumerable) + throw new InvalidOperationException("Value is not enumerable"); + + return DynamoDBList.Create(enumerable); + } + + public object FromEntry(DynamoDBEntry entry) + { + List? entries = entry.AsListOfDocument(); + if (entries is null) + return new EventList(); + + // Convert `entries` to List here... somehow + + var list = entries.Select(x => JsonConvert.DeserializeObject(x.ToJson())).Where(x => x != null); + + + return new EventList(list!); + } +} \ No newline at end of file diff --git a/InstarBot/DynamoModels/InstarDatabaseEntry.cs b/InstarBot/DynamoModels/InstarDatabaseEntry.cs new file mode 100644 index 0000000..938275f --- /dev/null +++ b/InstarBot/DynamoModels/InstarDatabaseEntry.cs @@ -0,0 +1,26 @@ +using Amazon.DynamoDBv2.DataModel; + +namespace PaxAndromeda.Instar.DynamoModels; + +/// +/// Represents a database entry in the Instar application. +/// Provides functionality to encapsulate data and interact with a DynamoDB database context. +/// +/// The type of the data stored in the database entry. +public sealed class InstarDatabaseEntry(IDynamoDBContext context, T data) +{ + /// + /// Represents a property that encapsulates the core data of a database entry. + /// This property holds the data model for the entry and is used within the context + /// of DynamoDB operations to save or update information in the associated table. + /// + public T Data { get; } = data; + + /// + /// Updates the corresponding database entry for the data encapsulated in the instance. + /// Persists changes to the underlying storage system asynchronously. + /// + /// A that can be used to poll or wait for results, or both + public Task UpdateAsync() + => context.SaveAsync(Data); +} \ No newline at end of file diff --git a/InstarBot/DynamoModels/InstarUserData.cs b/InstarBot/DynamoModels/InstarUserData.cs new file mode 100644 index 0000000..b5a09a6 --- /dev/null +++ b/InstarBot/DynamoModels/InstarUserData.cs @@ -0,0 +1,267 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; +using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; +using Discord; +using JetBrains.Annotations; + +namespace PaxAndromeda.Instar.DynamoModels; + +[DynamoDBTable("TestInstarData")] +[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] +public class InstarUserData +{ + [DynamoDBHashKey("user_id", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake? UserID { get; set; } + + [DynamoDBProperty("birthday")] + public DateTime? Birthday { get; set; } + + [DynamoDBProperty("joined")] + public DateTime? Joined { get; set; } + + [DynamoDBProperty("position", Converter = typeof(InstarEnumPropertyConverter))] + public InstarUserPosition? Position { get; set; } + + [DynamoDBProperty("avatars")] + public List>? Avatars { get; set; } + + [DynamoDBProperty("nicknames")] + public List>? Nicknames { get; set; } + + [DynamoDBProperty("usernames")] + public List>? Usernames { get; set; } + + [DynamoDBProperty("modlog")] + public List ModLogs { get; set; } + + [DynamoDBProperty("reports")] + public List Reports { get; set; } + + [DynamoDBProperty("notes")] + public List Notes { get; set; } + + [DynamoDBProperty("amh")] + public AutoMemberHoldRecord? AutoMemberHoldRecord { get; set; } + + public string Username + { + get => Usernames?.LastOrDefault()?.Data ?? ""; + set + { + var time = DateTime.UtcNow; + if (Usernames is null) + { + Usernames = [new InstarUserDataHistoricalEntry(time, value)]; + return; + } + + // Don't add a new username if the latest one matches the current one + if (Usernames.OrderByDescending(n => n.Date).First().Data == value) + return; + + Usernames.Add(new InstarUserDataHistoricalEntry(time, value)); + } + } + + public static InstarUserData CreateFrom(IGuildUser user) + { + return new InstarUserData + { + UserID = user.Id, + Birthday = null, + Joined = user.JoinedAt?.UtcDateTime, + Position = InstarUserPosition.NewMember, + Avatars = + [ + new InstarUserDataHistoricalEntry(DateTime.UtcNow, user.GetAvatarUrl(ImageFormat.Auto, 1024) ?? "") + ], + Nicknames = + [ + new InstarUserDataHistoricalEntry(DateTime.UtcNow, user.Nickname) + ], + Usernames = [ new InstarUserDataHistoricalEntry(DateTime.UtcNow, user.Username) ] + }; + } +} + +public record AutoMemberHoldRecord +{ + [DynamoDBProperty("date")] + public DateTime Date { get; set; } + + [DynamoDBProperty("mod", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake ModeratorID { get; set; } + + [DynamoDBProperty("reason")] + public string Reason { get; set; } +} + +public interface ITimedEvent +{ + DateTime Date { get; } +} + +[UsedImplicitly] +public record InstarUserDataHistoricalEntry : ITimedEvent +{ + [DynamoDBProperty("date")] + public DateTime Date { get; set; } + + [DynamoDBProperty("data")] + public T? Data { get; set; } + + public InstarUserDataHistoricalEntry() + { + Date = DateTime.UtcNow; + Data = default; + } + + public InstarUserDataHistoricalEntry(DateTime date, T data) + { + Date = date; + Data = data; + } +} + +[UsedImplicitly] +public record InstarUserDataNote +{ + [DynamoDBProperty("content")] + public string Content { get; set; } + + [DynamoDBProperty("date")] + public DateTime Date { get; set; } + + [DynamoDBProperty("mod", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake ModeratorID { get; set; } +} + +[UsedImplicitly] +public record InstarUserDataReports +{ + [DynamoDBProperty("message_content")] + public string MessageContent { get; set; } + + [DynamoDBProperty("reason")] + public string Reason { get; set; } + + [DynamoDBProperty("date")] + public DateTime Date { get; set; } + + [DynamoDBProperty("channel", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake Channel { get; set; } + + [DynamoDBProperty("message", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake Message { get; set; } + + [DynamoDBProperty("by_user", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake Reporter { get; set; } +} + +public record InstarModLogEntry +{ + [DynamoDBProperty("context")] + public string Context { get; set; } + + [DynamoDBProperty("date")] + public DateTime Date { get; set; } + + [DynamoDBProperty("mod", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake Moderator { get; set; } + + [DynamoDBProperty("reason")] + public string Reason { get; set; } + + [DynamoDBProperty("expiry")] + public DateTime? Expiry { get; set; } + + [DynamoDBProperty("type", Converter = typeof(InstarEnumPropertyConverter))] + public InstarModActionType Type { get; set; } +} + +public enum InstarUserPosition +{ + [EnumMember(Value = "OWNER")] + Owner, + [EnumMember(Value = "ADMIN")] + Admin, + [EnumMember(Value = "MODERATOR")] + Moderator, + [EnumMember(Value = "SENIOR_HELPER")] + SeniorHelper, + [EnumMember(Value = "HELPER")] + Helper, + [EnumMember(Value = "COMMUNITY_MANAGER")] + CommunityManager, + [EnumMember(Value = "MEMBER")] + Member, + [EnumMember(Value = "NEW_MEMBER")] + NewMember, + [EnumMember(Value = "UNKNOWN")] + Unknown +} + +public enum InstarModActionType +{ + [EnumMember(Value = "BAN")] + Ban, + [EnumMember(Value = "KICK")] + Kick, + [EnumMember(Value = "MUTE")] + Mute, + [EnumMember(Value = "WARN")] + Warn, + [EnumMember(Value = "VOICE_MUTE")] + VoiceMute, + [EnumMember(Value = "SOFT_BAN")] + Softban, + [EnumMember(Value = "VOICE_BAN")] + Voiceban, + [EnumMember(Value = "TIMEOUT")] + Timeout + +} + +public class InstarEnumPropertyConverter : IPropertyConverter where T : Enum +{ + public DynamoDBEntry ToEntry(object value) + { + var pos = (InstarUserPosition) value; + + var name = pos.GetAttributeOfType(); + return name?.Value ?? "UNKNOWN"; + } + + public object FromEntry(DynamoDBEntry entry) + { + var sEntry = entry.AsString(); + if (sEntry is null || string.IsNullOrWhiteSpace(entry.AsString())) + return InstarUserPosition.Unknown; + + var name = Utilities.ToEnum(sEntry); + + return name; + } +} + +public class InstarSnowflakePropertyConverter : IPropertyConverter +{ + public DynamoDBEntry ToEntry(object value) + { + return value switch + { + Snowflake snowflake => snowflake.ID.ToString(), + _ => value.ToString() + }; + } + + public object FromEntry(DynamoDBEntry entry) + { + var sEntry = entry.AsString(); + if (sEntry is null || string.IsNullOrWhiteSpace(entry.AsString()) || !ulong.TryParse(sEntry, out var id)) + return new Snowflake(0); + + return new Snowflake(id); + } +} \ No newline at end of file diff --git a/InstarBot/InstarBot.csproj b/InstarBot/InstarBot.csproj index 761299a..8b801ab 100644 --- a/InstarBot/InstarBot.csproj +++ b/InstarBot/InstarBot.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable PaxAndromeda.Instar @@ -10,33 +10,35 @@ - + $(DefineConstants) + + + + $(DefineConstants);DEBUG;TRACE + full - - - - + + + + - - - - - - - + + + + + + + - - - - - - + + + diff --git a/InstarBot/MembershipEligibility.cs b/InstarBot/MembershipEligibility.cs index fbfd35f..faf65b3 100644 --- a/InstarBot/MembershipEligibility.cs +++ b/InstarBot/MembershipEligibility.cs @@ -3,12 +3,14 @@ namespace PaxAndromeda.Instar; [Flags] public enum MembershipEligibility { - Eligible = 0x0, - NotEligible = 0x1, - AlreadyMember = 0x2, - TooYoung = 0x4, - MissingRoles = 0x8, - MissingIntroduction = 0x10, - PunishmentReceived = 0x20, - NotEnoughMessages = 0x40 + Invalid = 0x0, + Eligible = 0x1, + NotEligible = 0x2, + AlreadyMember = 0x4, + TooYoung = 0x8, + MissingRoles = 0x10, + MissingIntroduction = 0x20, + PunishmentReceived = 0x40, + NotEnoughMessages = 0x80, + AutoMemberHold = 0x100 } \ No newline at end of file diff --git a/InstarBot/Metrics/Metric.cs b/InstarBot/Metrics/Metric.cs index 3722db8..036a2a4 100644 --- a/InstarBot/Metrics/Metric.cs +++ b/InstarBot/Metrics/Metric.cs @@ -37,8 +37,16 @@ public enum Metric [MetricDimension("Service", "Auto Member System")] [MetricName("Users Granted Membership")] AMS_UsersGrantedMembership, - - [MetricDimension("Service", "Discord")] + + [MetricDimension("Service", "Auto Member System")] + [MetricName("DynamoDB Failures")] + AMS_DynamoFailures, + + [MetricDimension("Service", "Auto Member System")] + [MetricName("AMH Application Failures")] + AMS_AMHFailures, + + [MetricDimension("Service", "Discord")] [MetricName("Messages Sent")] Discord_MessagesSent, diff --git a/InstarBot/Modals/UserUpdatedEventArgs.cs b/InstarBot/Modals/UserUpdatedEventArgs.cs new file mode 100644 index 0000000..0c0a2e1 --- /dev/null +++ b/InstarBot/Modals/UserUpdatedEventArgs.cs @@ -0,0 +1,19 @@ +using Discord; + +namespace PaxAndromeda.Instar.Modals; + +public class UserUpdatedEventArgs(Snowflake id, IGuildUser before, IGuildUser after) +{ + public Snowflake ID { get; } = id; + + public IGuildUser Before { get; } = before; + + public IGuildUser After { get; } = after; + + // TODO: add additional parts to this for the data we care about + + /// + /// A flag indicating whether data we care about (e.g. nicknames, usernames) has changed. + /// + public bool HasUpdated => Before.Username != After.Username || Before.Nickname != After.Nickname; +} \ No newline at end of file diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index 6c3520e..b1bfbd7 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -29,10 +29,9 @@ public static async Task Main(string[] args) var configPath = "Config/Instar.debug.conf.json"; #else var configPath = "Config/Instar.conf.json"; -#endif - if (!string.IsNullOrEmpty(cli.ConfigPath)) configPath = cli.ConfigPath; +#endif Log.Information("Config path is {Path}", configPath); IConfiguration config = new ConfigurationBuilder() @@ -125,7 +124,7 @@ private static ServiceProvider ConfigureServices(IConfiguration config) services.AddTransient(); services.AddTransient(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // Commands & Interactions @@ -133,6 +132,7 @@ private static ServiceProvider ConfigureServices(IConfiguration config) services.AddTransient(); services.AddSingleton(); services.AddTransient(); + services.AddTransient(); return services.BuildServiceProvider(); } diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index 28ce01e..79e717e 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -5,13 +5,15 @@ using Discord.WebSocket; using PaxAndromeda.Instar.Caching; using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Metrics; +using PaxAndromeda.Instar.Modals; using Serilog; using Timer = System.Timers.Timer; namespace PaxAndromeda.Instar.Services; -public sealed class AutoMemberSystem +public sealed class AutoMemberSystem : IAutoMemberSystem { private readonly MemoryCache _ddbCache = new("AutoMemberSystem_DDBCache"); private readonly MemoryCache _messageCache = new("AutoMemberSystem_MessageCache"); @@ -42,6 +44,7 @@ public AutoMemberSystem(IDynamicConfigService dynamicConfig, IDiscordService dis _metricService = metricService; discord.UserJoined += HandleUserJoined; + discord.UserUpdated += HandleUserUpdated; discord.MessageReceived += HandleMessageReceived; discord.MessageDeleted += HandleMessageDeleted; @@ -119,20 +122,79 @@ private async Task HandleUserJoined(IGuildUser user) { var cfg = await _dynamicConfig.GetConfig(); - if (await WasUserGrantedMembershipBefore(user.Id)) + var dbUser = await _ddbService.GetUserAsync(user.Id); + if (dbUser is null) { - Log.Information("User {UserID} has been granted membership before. Granting membership again", user.Id); - await GrantMembership(cfg, user); + // Let's create a new user + await _ddbService.CreateUserAsync(InstarUserData.CreateFrom(user)); } else { - await user.AddRoleAsync(cfg.NewMemberRoleID); + switch (dbUser.Data.Position) + { + case InstarUserPosition.NewMember: + case InstarUserPosition.Unknown: + await user.AddRoleAsync(cfg.NewMemberRoleID); + dbUser.Data.Position = InstarUserPosition.NewMember; + await dbUser.UpdateAsync(); + break; + + default: + // Yes, they were a member + Log.Information("User {UserID} has been granted membership before. Granting membership again", user.Id); + await GrantMembership(cfg, user, dbUser); + break; + } } await _metricService.Emit(Metric.Discord_UsersJoined, 1); } - - private void StartTimer() + + private async Task HandleUserUpdated(UserUpdatedEventArgs arg) + { + if (!arg.HasUpdated) + return; + + var user = await _ddbService.GetUserAsync(arg.ID); + if (user is null) + { + // new user for the database, create from the latest data and return + try + { + await _ddbService.CreateUserAsync(InstarUserData.CreateFrom(arg.After)); + Log.Information("Created new user {Username} (user ID {UserID})", arg.After.Username, arg.ID); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to create user with ID {UserID}, username {Username}", arg.ID, arg.After.Username); + } + + return; + } + + // Update the record + bool changed = false; + + if (arg.Before.Username != arg.After.Username) + { + user.Data.Username = arg.After.Username; + changed = true; + } + + if (arg.Before.Nickname != arg.After.Nickname) + { + user.Data.Nicknames?.Add(new InstarUserDataHistoricalEntry(DateTime.UtcNow, arg.After.Nickname)); + changed = true; + } + + if (changed) + { + Log.Information("Updated metadata for user {Username} (user ID {UserID})", arg.After.Username, arg.ID); + await user.UpdateAsync(); + } + } + + private void StartTimer() { // Since we can start the bot in the middle of an hour, // first we must determine the time until the next top @@ -170,7 +232,7 @@ public async Task RunAsync() await _metricService.Emit(Metric.AMS_Runs, 1); var cfg = await _dynamicConfig.GetConfig(); - // Caution: This is an extremely long-running method! + // Caution: This is an extremely long-running method! Log.Information("Beginning auto member routine"); if (cfg.AutoMemberConfig.EnableGaiusCheck) @@ -195,26 +257,44 @@ public async Task RunAsync() var membershipGrants = 0; - newMembers = await newMembers.ToAsyncEnumerable() - .WhereAwait(async user => await CheckEligibility(user) == MembershipEligibility.Eligible).ToListAsync(); + var eligibleMembers = newMembers.Where(user => CheckEligibility(cfg, user) == MembershipEligibility.Eligible) + .ToDictionary(n => new Snowflake(n.Id), n => n); - Log.Verbose("There are {NumNewMembers} users eligible for membership", newMembers.Count); - - foreach (var user in newMembers) - { - // User has all the qualifications, let's update their role - try - { - await GrantMembership(cfg, user); - membershipGrants++; - - Log.Information("Granted {UserId} membership", user.Id); - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to grant user {UserId} membership", user.Id); - } - } + Log.Verbose("There are {NumNewMembers} users eligible for membership", eligibleMembers.Count); + + // Batch get users to save bandwidth + var userData = ( + await _ddbService.GetBatchUsersAsync( + eligibleMembers.Select(n => n.Key)) + ) + .Select(x => (x.Data.UserID!, x)) + .ToDictionary(); + + // Determine which users are not present in DDB and need to be created + var usersToCreate = eligibleMembers.Where(n => !userData.ContainsKey(n.Key)).Select(n => n.Value); + + // Step 1: Create missing users in DDB + await foreach (var (id, user) in CreateMissingUsers(usersToCreate)) + userData.Add(id, user); + + // Step 2: Grant membership to eligible users + foreach (var (id, dbUser) in userData) + { + try + { + // User has all the qualifications, let's update their role + if (!eligibleMembers.TryGetValue(id, out var user)) + throw new BadStateException("Unexpected state: expected ID is missing from eligibleMembers"); + + await GrantMembership(cfg, user, dbUser); + membershipGrants++; + + Log.Information("Granted {UserId} membership", user.Id); + } catch (Exception ex) + { + Log.Warning(ex, "Failed to grant user {UserId} membership", id); + } + } await _metricService.Emit(Metric.AMS_UsersGrantedMembership, membershipGrants); } @@ -224,29 +304,45 @@ public async Task RunAsync() } } - private async Task WasUserGrantedMembershipBefore(Snowflake snowflake) - { - if (_ddbCache.Contains(snowflake.ID.ToString()) && (bool)_ddbCache[snowflake.ID.ToString()]) - return true; - - var grantedMembership = await _ddbService.GetUserMembership(snowflake.ID); - if (grantedMembership is null) - return false; - - // Cache for 6-hour sliding window. If accessed, time is reset. - _ddbCache.Add(snowflake.ID.ToString(), grantedMembership.Value, new CacheItemPolicy - { - SlidingExpiration = TimeSpan.FromHours(6) - }); - - return grantedMembership.Value; - } - - private async Task GrantMembership(InstarDynamicConfiguration cfg, IGuildUser user) + private async IAsyncEnumerable>> CreateMissingUsers(IEnumerable users) + { + foreach (var user in users) + { + InstarDatabaseEntry? dbUser; + try + { + await _ddbService.CreateUserAsync(InstarUserData.CreateFrom(user)); + + // Now, get the user we just created + dbUser = await _ddbService.GetUserAsync(user.Id); + + if (dbUser is null) + { + // Welp, something's wrong with DynamoDB that isn't throwing an + // exception with CreateUserAsync or GetUserAsync. At this point, + // we expect the user to be present in DynamoDB, so we'll treat + // this as an error. + throw new BadStateException("Expected user to be created and returned from DynamoDB"); + } + } catch (Exception ex) + { + await _metricService.Emit(Metric.AMS_DynamoFailures, 1); + Log.Error(ex, "Failed to get or create user with ID {UserID} in DynamoDB", user.Id); + continue; + } + + yield return new KeyValuePair>(user.Id, dbUser); + } + } + + private async Task GrantMembership(InstarDynamicConfiguration cfg, IGuildUser user, + InstarDatabaseEntry dbUser) { await user.AddRoleAsync(cfg.MemberRoleID); await user.RemoveRoleAsync(cfg.NewMemberRoleID); - await _ddbService.UpdateUserMembership(user.Id, true); + + dbUser.Data.Position = InstarUserPosition.Member; + await dbUser.UpdateAsync(); // Remove the cache entry if (_ddbCache.Contains(user.Id.ToString())) @@ -256,10 +352,25 @@ private async Task GrantMembership(InstarDynamicConfiguration cfg, IGuildUser us _introductionPosters.TryRemove(user.Id, out _); } - public async Task CheckEligibility(IGuildUser user) + /// + /// Determines the eligibility of a user for membership based on specific criteria. + /// + /// The current configuration from AppConfig. + /// The user whose eligibility is being evaluated. + /// An enumeration value of type that indicates the user's membership eligibility status. + /// + /// The criteria for membership is as follows: + /// + /// The user must have the required roles (see ) + /// The user must be on the server for a configurable minimum amount of time + /// The user must have posted an introduction + /// The user must have posted enough messages in a configurable amount of time + /// The user must not have been issued a moderator action + /// The user must not already be a member + /// + /// + public MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IGuildUser user) { - var cfg = await _dynamicConfig.GetConfig(); - // We need recent messages here, so load it into // context if it does not exist, such as when the // bot first starts and has not run AMS yet. @@ -285,13 +396,25 @@ public async Task CheckEligibility(IGuildUser user) if (_punishedUsers.ContainsKey(user.Id)) eligibility |= MembershipEligibility.PunishmentReceived; - if (eligibility != MembershipEligibility.Eligible) - eligibility |= MembershipEligibility.NotEligible; + if (user.RoleIds.Contains(cfg.AutoMemberConfig.HoldRole)) + eligibility |= MembershipEligibility.AutoMemberHold; + + if (eligibility != MembershipEligibility.Eligible) + { + eligibility &= ~MembershipEligibility.Eligible; + eligibility |= MembershipEligibility.NotEligible; + } Log.Verbose("User {User} ({UserID}) membership eligibility: {Eligibility}", user.Username, user.Id, eligibility); return eligibility; } + /// + /// Verifies if a user possesses the required roles for automatic membership based on the provided configuration. + /// + /// The dynamic configuration containing role requirements and settings for automatic membership. + /// The user whose roles are being checked against the configuration. + /// True if the user satisfies the role requirements; otherwise, false. private static bool CheckUserRequiredRoles(InstarDynamicConfiguration cfg, IGuildUser user) { // Auto Member Hold overrides all role permissions diff --git a/InstarBot/Services/CloudwatchMetricService.cs b/InstarBot/Services/CloudwatchMetricService.cs index 165c1dd..6dc9b70 100644 --- a/InstarBot/Services/CloudwatchMetricService.cs +++ b/InstarBot/Services/CloudwatchMetricService.cs @@ -30,7 +30,8 @@ public async Task Emit(Metric metric, double value) var datum = new MetricDatum { MetricName = nameAttr is not null ? nameAttr.Name : Enum.GetName(metric), - Value = value + Value = value, + Dimensions = [] }; var attrs = metric.GetAttributesOfType(); diff --git a/InstarBot/Services/DiscordService.cs b/InstarBot/Services/DiscordService.cs index 8284b81..c2fbfe6 100644 --- a/InstarBot/Services/DiscordService.cs +++ b/InstarBot/Services/DiscordService.cs @@ -1,13 +1,14 @@ -using System.Diagnostics.CodeAnalysis; -using Discord; +using Discord; using Discord.Interactions; using Discord.WebSocket; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Wrappers; using Serilog; using Serilog.Events; +using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Services; @@ -22,17 +23,24 @@ public sealed class DiscordService : IDiscordService private readonly IServiceProvider _provider; private readonly IDynamicConfigService _dynamicConfig; private readonly DiscordSocketClient _socketClient; - private readonly AsyncEvent _userJoinedEvent = new(); - private readonly AsyncEvent _messageReceivedEvent = new(); + private readonly AsyncEvent _userJoinedEvent = new(); + private readonly AsyncEvent _userUpdatedEvent = new(); + private readonly AsyncEvent _messageReceivedEvent = new(); private readonly AsyncEvent _messageDeletedEvent = new(); - public event Func UserJoined - { - add => _userJoinedEvent.Add(value); - remove => _userJoinedEvent.Remove(value); - } + public event Func UserJoined + { + add => _userJoinedEvent.Add(value); + remove => _userJoinedEvent.Remove(value); + } + + public event Func UserUpdated + { + add => _userUpdatedEvent.Add(value); + remove => _userUpdatedEvent.Remove(value); + } - public event Func MessageReceived + public event Func MessageReceived { add => _messageReceivedEvent.Add(value); remove => _messageReceivedEvent.Remove(value); @@ -70,6 +78,7 @@ public DiscordService(IServiceProvider provider, IConfiguration config, IDynamic _socketClient.MessageReceived += async message => await _messageReceivedEvent.Invoke(message); _socketClient.MessageDeleted += async (msgCache, _) => await _messageDeletedEvent.Invoke(msgCache.Id); _socketClient.UserJoined += async user => await _userJoinedEvent.Invoke(user); + _socketClient.GuildMemberUpdated += HandleUserUpdate; _interactionService.Log += HandleDiscordLog; _contextCommands = provider.GetServices().ToDictionary(n => n.Name, n => n); @@ -81,6 +90,16 @@ public DiscordService(IServiceProvider provider, IConfiguration config, IDynamic throw new ConfigurationException("TargetGuild is not set"); } + private async Task HandleUserUpdate(Cacheable before, SocketGuildUser after) + { + // Can't do anything if we don't have a before state. + // In the case of a user join, that is handled in UserJoined event. + if (!before.HasValue) + return; + + await _userUpdatedEvent.Invoke(new UserUpdatedEventArgs(after.Id, before.Value, after)); + } + private async Task HandleMessageCommand(SocketMessageCommand arg) { Log.Information("Message command: {CommandName}", arg.CommandName); diff --git a/InstarBot/Services/IAutoMemberSystem.cs b/InstarBot/Services/IAutoMemberSystem.cs new file mode 100644 index 0000000..5490a28 --- /dev/null +++ b/InstarBot/Services/IAutoMemberSystem.cs @@ -0,0 +1,28 @@ +using Discord; +using PaxAndromeda.Instar.ConfigModels; + +namespace PaxAndromeda.Instar.Services; + +public interface IAutoMemberSystem +{ + Task RunAsync(); + + /// + /// Determines the eligibility of a user for membership based on specific criteria. + /// + /// The current configuration from AppConfig. + /// The user whose eligibility is being evaluated. + /// An enumeration value of type that indicates the user's membership eligibility status. + /// + /// The criteria for membership is as follows: + /// + /// The user must have the required roles (see ) + /// The user must be on the server for a configurable minimum amount of time + /// The user must have posted an introduction + /// The user must have posted enough messages in a configurable amount of time + /// The user must not have been issued a moderator action + /// The user must not already be a member + /// + /// + MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IGuildUser user); +} \ No newline at end of file diff --git a/InstarBot/Services/IDiscordService.cs b/InstarBot/Services/IDiscordService.cs index cc7b740..49d312b 100644 --- a/InstarBot/Services/IDiscordService.cs +++ b/InstarBot/Services/IDiscordService.cs @@ -1,11 +1,13 @@ using Discord; +using PaxAndromeda.Instar.Modals; namespace PaxAndromeda.Instar.Services; public interface IDiscordService { event Func UserJoined; - event Func MessageReceived; + event Func UserUpdated; + event Func MessageReceived; event Func MessageDeleted; Task Start(IServiceProvider provider); diff --git a/InstarBot/Services/IInstarDDBService.cs b/InstarBot/Services/IInstarDDBService.cs index c4c954d..40e61af 100644 --- a/InstarBot/Services/IInstarDDBService.cs +++ b/InstarBot/Services/IInstarDDBService.cs @@ -1,14 +1,44 @@ using System.Diagnostics.CodeAnalysis; +using Discord; +using PaxAndromeda.Instar.DynamoModels; namespace PaxAndromeda.Instar.Services; [SuppressMessage("ReSharper", "UnusedMemberInSuper.Global")] public interface IInstarDDBService { - Task UpdateUserBirthday(Snowflake snowflake, DateTime birthday); - Task UpdateUserJoinDate(Snowflake snowflake, DateTime joinDate); - Task UpdateUserMembership(Snowflake snowflake, bool membershipGranted); - Task GetUserBirthday(Snowflake snowflake); - Task GetUserJoinDate(Snowflake snowflake); - Task GetUserMembership(Snowflake snowflake); + /// + /// Retrieves user data from DynamoDB for a provided . + /// + /// The user ID + /// User data associated with the provided , if any exists + /// If the `position` entry does not represent a valid + Task?> GetUserAsync(Snowflake snowflake); + + + /// + /// Retrieves or creates user data from a provided . + /// + /// An instance of . If a new user must be created, + /// information will be pulled from the parameter. + /// An instance of . + /// + /// When a new user is created with this method, it is *not* created in DynamoDB until + /// is called. + /// + Task> GetOrCreateUserAsync(IGuildUser user); + + /// + /// Retrieves a list of user data from a list of . + /// + /// A list of user ID snowflakes to query. + /// A list of containing for the provided . + Task>> GetBatchUsersAsync(IEnumerable snowflakes); + + /// + /// Creates a new user in DynamoDB. + /// + /// An instance of to save into DynamoDB. + /// Nothing. + Task CreateUserAsync(InstarUserData data); } \ No newline at end of file diff --git a/InstarBot/Services/InstarDDBService.cs b/InstarBot/Services/InstarDDBService.cs index 4dacfdd..3d8bf79 100644 --- a/InstarBot/Services/InstarDDBService.cs +++ b/InstarBot/Services/InstarDDBService.cs @@ -1,8 +1,10 @@ using System.Diagnostics.CodeAnalysis; using Amazon; using Amazon.DynamoDBv2; -using Amazon.DynamoDBv2.DocumentModel; +using Amazon.DynamoDBv2.DataModel; +using Discord; using Microsoft.Extensions.Configuration; +using PaxAndromeda.Instar.DynamoModels; using Serilog; namespace PaxAndromeda.Instar.Services; @@ -10,113 +12,54 @@ namespace PaxAndromeda.Instar.Services; [ExcludeFromCodeCoverage] public sealed class InstarDDBService : IInstarDDBService { - private const string TableName = "InstarUserData"; - private const string PrimaryKey = "UserID"; - private const string DateTimeFormat = "yyyy-MM-ddTHH:mm:ssK"; // ISO-8601 - - private readonly AmazonDynamoDBClient _client; + private readonly DynamoDBContext _ddbContext; public InstarDDBService(IConfiguration config) { var region = config.GetSection("AWS").GetValue("Region"); - _client = new AmazonDynamoDBClient(new AWSIAMCredential(config), RegionEndpoint.GetBySystemName(region)); - } - - public async Task UpdateUserBirthday(Snowflake snowflake, DateTime birthday) - { - return await UpdateUserData(snowflake, - DataType.Birthday, - (DynamoDBEntry)birthday.ToString(DateTimeFormat)); - } - - public async Task UpdateUserJoinDate(Snowflake snowflake, DateTime joinDate) - { - return await UpdateUserData(snowflake, - DataType.JoinDate, - (DynamoDBEntry)joinDate.ToString(DateTimeFormat)); - } - - public async Task UpdateUserMembership(Snowflake snowflake, bool membershipGranted) - { - return await UpdateUserData(snowflake, - DataType.Membership, - (DynamoDBEntry)membershipGranted); - } - - public async Task GetUserBirthday(Snowflake snowflake) - { - var entry = await GetUserData(snowflake, DataType.Birthday); - - if (!DateTimeOffset.TryParse(entry?.AsString(), out var dto)) - return null; + var client = new AmazonDynamoDBClient(new AWSIAMCredential(config), RegionEndpoint.GetBySystemName(region)); + _ddbContext = new DynamoDBContextBuilder() + .WithDynamoDBClient(() => client) + .Build(); - return dto; + _ddbContext.ConverterCache.Add(typeof(InstarUserDataHistoricalEntry), new ArbitraryDynamoDBTypeConverter>()); } - - public async Task GetUserJoinDate(Snowflake snowflake) + + public async Task?> GetUserAsync(Snowflake snowflake) { - var entry = await GetUserData(snowflake, DataType.JoinDate); - - if (!DateTimeOffset.TryParse(entry?.AsString(), out var dto)) + try + { + var result = await _ddbContext.LoadAsync(snowflake.ID.ToString()); + return result is null ? null : new InstarDatabaseEntry(_ddbContext, result); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to get user data for {Snowflake}", snowflake); return null; - - return dto; - } - - public async Task GetUserMembership(Snowflake snowflake) - { - var entry = await GetUserData(snowflake, DataType.Membership); - return entry?.AsBoolean(); + } } - private async Task UpdateUserData(Snowflake snowflake, DataType dataType, T data) - where T : DynamoDBEntry + public async Task> GetOrCreateUserAsync(IGuildUser user) { - var table = new TableBuilder(_client, TableName).Build(); + var data = await _ddbContext.LoadAsync(user.Id.ToString()) ?? InstarUserData.CreateFrom(user); - var updateData = new Document(new Dictionary - { - { PrimaryKey, snowflake.ID.ToString() }, - { dataType.ToString(), data } - }); - - var result = await table.UpdateItemAsync(updateData, new UpdateItemOperationConfig - { - ReturnValues = ReturnValues.AllNewAttributes - }); - - return result[PrimaryKey].AsULong() == snowflake.ID && - result[dataType.ToString()].AsString().Equals(data.AsString()); + return new InstarDatabaseEntry(_ddbContext, data); } - private async Task GetUserData(Snowflake snowflake, DataType dataType) + public async Task>> GetBatchUsersAsync(IEnumerable snowflakes) { - var table = new TableBuilder(_client, TableName).Build(); - var scan = table.Query(new Primitive(snowflake.ID.ToString()), new QueryFilter()); - - var results = await scan.GetRemainingAsync(); - - switch (results.Count) - { - case > 1: - Log.Warning("Found duplicate user {UserID} in database!", snowflake.ID); - break; - case 0: - return null; - } + var batches = _ddbContext.CreateBatchGet(); + foreach (var snowflake in snowflakes) + batches.AddKey(snowflake.ID.ToString()); - if (results.First().TryGetValue(dataType.ToString(), out var entry)) - return entry; + await _ddbContext.ExecuteBatchGetAsync(batches); - Log.Warning("Failed to query data type {DataType} for user ID {UserID}", dataType, snowflake.ID); - return null; + return batches.Results.Select(x => new InstarDatabaseEntry(_ddbContext, x)).ToList(); } - private enum DataType + public async Task CreateUserAsync(InstarUserData data) { - Birthday, - JoinDate, - Membership + await _ddbContext.SaveAsync(data); } } \ No newline at end of file diff --git a/InstarBot/Utilities.cs b/InstarBot/Utilities.cs index 216c6d4..a1a0eb6 100644 --- a/InstarBot/Utilities.cs +++ b/InstarBot/Utilities.cs @@ -1,9 +1,92 @@ using System.Reflection; +using System.Runtime.Serialization; namespace PaxAndromeda.Instar; +public static class EnumExtensions +{ + private static class EnumCache where T : Enum + { + public static readonly IReadOnlyDictionary Map = + typeof(T) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(f => ( + Field: f, + Value: f.GetCustomAttribute()?.Value ?? f.Name, + EnumValue: (T)f.GetValue(null)! + )) + .ToDictionary( + x => x.Value, + x => x.EnumValue, + StringComparer.OrdinalIgnoreCase); + } + + /// + /// Attempts to parse a string representation of an enum value or its associated + /// value to its corresponding enum value. + /// + /// The type of the enum to parse. + /// The string representation of the enum value or its associated + /// value. + /// When this method returns, contains the enum value if parsing succeeded; + /// otherwise, the default value of the enum. + /// + /// true if the string value was successfully parsed to an enum value; + /// otherwise, false. + /// + public static bool TryParseEnumMember(string value, out T result) where T : Enum + { + // NULLABILITY: result can be null if TryGetValue returns false + return EnumCache.Map.TryGetValue(value, out result!); + } + + /// + /// Attempts to parse a string representation of an enum value or its associated + /// value to its corresponding enum value. Throws an exception + /// if the specified value cannot be parsed. + /// + /// The type of the enum to parse. + /// The string representation of the enum value or its associated + /// value. + /// Returns the corresponding enum value of type . + /// Thrown when the specified value does not match any enum value + /// or associated value in the enum type . + public static T ParseEnumMember(string value) where T : Enum + { + return TryParseEnumMember(value, out T result) + ? result + : throw new ArgumentException($"Value '{value}' not found in enum {typeof(T).Name}"); + } + + /// + /// Retrieves the string representation of an enum value as defined by its associated + /// value, or the enum value's name if no attribute is present. + /// + /// The type of the enum. + /// The enum value to retrieve the string representation for. + /// + /// The string representation of the enum value as defined by the , + /// or the enum value's name if no attribute is present. + /// + public static string GetEnumMemberValue(this T value) where T : Enum + { + return EnumCache.Map + .FirstOrDefault(x => EqualityComparer.Default.Equals(x.Value, value)) + .Key ?? value.ToString(); + } +} + public static class Utilities { + /// + /// Retrieves a list of attributes of a specified type defined on an enum value . + /// + /// The type of the attribute to retrieve. + /// The enum value whose attributes are to be retrieved. + /// + /// A list of attributes of the specified type associated with the enum value; + /// or null if no attributes of the specified type are found. + /// public static List? GetAttributesOfType(this Enum enumVal) where T : Attribute { var type = enumVal.GetType(); @@ -14,11 +97,49 @@ public static class Utilities var attributes = membersInfo[0].GetCustomAttributes(typeof(T), false); return attributes.Length > 0 ? attributes.OfType().ToList() : null; } - + + /// + /// Retrieves the first custom attribute of the specified type applied to the + /// member that corresponds to the given enum value . + /// + /// The type of attribute to retrieve. + /// The enum value whose member's custom attribute is retrieved. + /// + /// The first custom attribute of type if found; + /// otherwise, null. + /// public static T? GetAttributeOfType(this Enum enumVal) where T : Attribute { var type = enumVal.GetType(); var membersInfo = type.GetMember(enumVal.ToString()); return membersInfo.Length == 0 ? null : membersInfo[0].GetCustomAttribute(false); } + + /// + /// Converts the string representation of an enum value or its associated + /// value to its corresponding enum value of type . + /// + /// The type of the enum to convert to. + /// The string representation of the enum value or its associated + /// value. + /// The corresponding enum value of type . + /// Thrown when the specified string does not match any + /// enum value or associated value in the enum type . + public static T ToEnum(string name) where T : Enum + { + return EnumExtensions.ParseEnumMember(name); + } + + /// + /// Converts a string in SCREAMING_SNAKE_CASE format to PascalCase format. + /// + /// The input string in SCREAMING_SNAKE_CASE format. + /// + /// A string converted from SCREAMING_SNAKE_CASE to PascalCase format. + /// + public static string ScreamingToPascalCase(string input) + { + // COMMUNITY_MANAGER ⇒ CommunityManager + return input.Split('_').Select(piece => piece[0] + piece[1..].ToLower()).Aggregate((a, b) => a + b); + } } \ No newline at end of file From c769bc478925802c87d1d15cd2288a839fd98c72 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Tue, 25 Nov 2025 12:32:07 -0800 Subject: [PATCH 08/53] Added centralized strings management and more integration tests. --- InstarBot.Tests.Common/EmbedVerifier.cs | 149 ++++++ InstarBot.Tests.Common/MetaTests.cs | 42 ++ .../MockInstarDDBServiceTests.cs | 45 ++ .../Models/TestGuildUser.cs | 12 +- InstarBot.Tests.Common/Models/TestUser.cs | 82 +++ .../Services/MockInstarDDBService.cs | 120 +++-- InstarBot.Tests.Common/TestUtilities.cs | 143 ++++- ...arBot.Tests.Integration.csproj.DotSettings | 9 +- .../AutoMemberSystemCommandTests.cs | 224 ++++++++ .../CheckEligibilityCommandTests.cs | 331 +++++++++--- .../Interactions/PageCommandTests.cs | 165 +++--- .../Interactions/ReportUserTests.cs | 21 +- InstarBot/Commands/AutoMemberHoldCommand.cs | 23 +- InstarBot/Commands/CheckEligibilityCommand.cs | 209 +------- InstarBot/Commands/PageCommand.cs | 64 +-- InstarBot/Commands/ReportUserCommand.cs | 48 +- .../Embeds/InstarAutoMemberSystemEmbed.cs | 28 + .../Embeds/InstarCheckEligibilityAMHEmbed.cs | 26 + .../Embeds/InstarCheckEligibilityEmbed.cs | 99 ++++ InstarBot/Embeds/InstarEligibilityEmbed.cs | 71 +++ InstarBot/Embeds/InstarEmbed.cs | 10 + InstarBot/Embeds/InstarPageEmbed.cs | 44 ++ InstarBot/Embeds/InstarReportUserEmbed.cs | 51 ++ InstarBot/InstarBot.csproj | 15 + InstarBot/InstarBot.csproj.DotSettings | 2 + InstarBot/Metrics/MetricDimensionAttribute.cs | 3 + InstarBot/Metrics/MetricNameAttribute.cs | 3 + InstarBot/Services/AutoMemberSystem.cs | 2 + InstarBot/Strings.Designer.cs | 495 ++++++++++++++++++ InstarBot/Strings.resx | 287 ++++++++++ 30 files changed, 2322 insertions(+), 501 deletions(-) create mode 100644 InstarBot.Tests.Common/EmbedVerifier.cs create mode 100644 InstarBot.Tests.Common/MetaTests.cs create mode 100644 InstarBot.Tests.Common/MockInstarDDBServiceTests.cs create mode 100644 InstarBot.Tests.Common/Models/TestUser.cs create mode 100644 InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs create mode 100644 InstarBot/Embeds/InstarAutoMemberSystemEmbed.cs create mode 100644 InstarBot/Embeds/InstarCheckEligibilityAMHEmbed.cs create mode 100644 InstarBot/Embeds/InstarCheckEligibilityEmbed.cs create mode 100644 InstarBot/Embeds/InstarEligibilityEmbed.cs create mode 100644 InstarBot/Embeds/InstarEmbed.cs create mode 100644 InstarBot/Embeds/InstarPageEmbed.cs create mode 100644 InstarBot/Embeds/InstarReportUserEmbed.cs create mode 100644 InstarBot/InstarBot.csproj.DotSettings create mode 100644 InstarBot/Strings.Designer.cs create mode 100644 InstarBot/Strings.resx diff --git a/InstarBot.Tests.Common/EmbedVerifier.cs b/InstarBot.Tests.Common/EmbedVerifier.cs new file mode 100644 index 0000000..f17ac41 --- /dev/null +++ b/InstarBot.Tests.Common/EmbedVerifier.cs @@ -0,0 +1,149 @@ +using System.Collections.Immutable; +using Discord; +using Serilog; + +namespace InstarBot.Tests; + +[Flags] +public enum EmbedVerifierMatchFlags +{ + None, + PartialTitle, + PartialDescription, + PartialAuthorName, + PartialFooterText +} + +public class EmbedVerifier +{ + public EmbedVerifierMatchFlags MatchFlags { get; set; } = EmbedVerifierMatchFlags.None; + + public string? Title { get; set; } + public string? Description { get; set; } + + public string? AuthorName { get; set; } + public string? FooterText { get; set; } + + private readonly List<(string?, string, bool)> _fields = []; + + public void AddField(string name, string value, bool partial = false) + { + _fields.Add((name, value, partial)); + } + + public void AddFieldValue(string value, bool partial = false) + { + _fields.Add((null, value, partial)); + } + + public bool Verify(Embed embed) + { + if (!VerifyString(Title, embed.Title, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialTitle))) + { + Log.Error("Failed to match title: Expected '{Expected}', got '{Actual}'", Title, embed.Title); + return false; + } + + if (!VerifyString(Description, embed.Description, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialDescription))) + { + Log.Error("Failed to match description: Expected '{Expected}', got '{Actual}'", Description, embed.Description); + return false; + } + + if (!VerifyString(AuthorName, embed.Author?.Name, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialAuthorName))) + { + Log.Error("Failed to match author name: Expected '{Expected}', got '{Actual}'", AuthorName, embed.Author?.Name); + return false; + } + + if (!VerifyString(FooterText, embed.Footer?.Text, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialFooterText))) + { + Log.Error("Failed to match footer text: Expected '{Expected}', got '{Actual}'", FooterText, embed.Footer?.Text); + return false; + } + + + return VerifyFields(embed.Fields); + } + + private bool VerifyFields(ImmutableArray embedFields) + { + foreach (var (name, value, partial) in _fields) + { + if (!embedFields.Any(n => VerifyString(name, n.Name, partial) && VerifyString(value, n.Value, partial))) + { + Log.Error("Failed to match field: Expected Name '{ExpectedName}', Value '{ExpectedValue}'", name, value); + return false; + } + } + + return true; + } + + private static bool VerifyString(string? expected, string? actual, bool partial = false) + { + if (expected is null) + return true; + if (actual is null) + return false; + + + if (expected.Contains("{") && expected.Contains("}")) + return TestUtilities.MatchesFormat(actual, expected, partial); + + return partial ? actual.Contains(expected, StringComparison.Ordinal) : actual.Equals(expected, StringComparison.Ordinal); + } + + public static VerifierBuilder Builder() => VerifierBuilder.Create(); + + public sealed class VerifierBuilder + { + private readonly EmbedVerifier _verifier = new(); + + public static VerifierBuilder Create() => new(); + + public VerifierBuilder WithFlags(EmbedVerifierMatchFlags flags) + { + _verifier.MatchFlags = flags; + return this; + } + + public VerifierBuilder WithTitle(string? title) + { + _verifier.Title = title; + return this; + } + + public VerifierBuilder WithDescription(string? description) + { + _verifier.Description = description; + return this; + } + + public VerifierBuilder WithAuthorName(string? authorName) + { + _verifier.AuthorName = authorName; + return this; + } + + public VerifierBuilder WithFooterText(string? footerText) + { + _verifier.FooterText = footerText; + return this; + } + + public VerifierBuilder WithField(string name, string value, bool partial = false) + { + _verifier.AddField(name, value, partial); + return this; + } + + public VerifierBuilder WithField(string value, bool partial = false) + { + _verifier.AddFieldValue(value, partial); + return this; + } + + public EmbedVerifier Build() => _verifier; + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/MetaTests.cs b/InstarBot.Tests.Common/MetaTests.cs new file mode 100644 index 0000000..6a30743 --- /dev/null +++ b/InstarBot.Tests.Common/MetaTests.cs @@ -0,0 +1,42 @@ +using FluentAssertions; +using Xunit; + +namespace InstarBot.Tests; + +public class MetaTests +{ + [Fact] + public static void MatchesFormat_WithValidText_ShouldReturnTrue() + { + const string text = "You are missing an age role."; + const string format = "You are missing {0} {1} role."; + + bool result = TestUtilities.MatchesFormat(text, format); + + result.Should().BeTrue(); + } + [Fact] + public static void MatchesFormat_WithRegexReservedCharacters_ShouldReturnTrue() + { + const string text = "You are missing an age **role**."; + const string format = "You are missing {0} {1} **role**."; + + bool result = TestUtilities.MatchesFormat(text, format); + + result.Should().BeTrue(); + } + + [Theory] + [InlineData("You are missing age role.")] + [InlineData("You are missing an role.")] + [InlineData("")] + [InlineData("luftputefartøyet mitt er fullt av Ã¥l")] + public static void MatchesFormat_WithBadText_ShouldReturnTrue(string text) + { + const string format = "You are missing {0} {1} role."; + + bool result = TestUtilities.MatchesFormat(text, format); + + result.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/MockInstarDDBServiceTests.cs b/InstarBot.Tests.Common/MockInstarDDBServiceTests.cs new file mode 100644 index 0000000..a93b704 --- /dev/null +++ b/InstarBot.Tests.Common/MockInstarDDBServiceTests.cs @@ -0,0 +1,45 @@ +using FluentAssertions; +using InstarBot.Tests.Models; +using InstarBot.Tests.Services; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.DynamoModels; +using Xunit; + +namespace InstarBot.Tests; + +public class MockInstarDDBServiceTests +{ + [Fact] + public static async Task UpdateMember_ShouldPersist() + { + // Arrange + var mockDDB = new MockInstarDDBService(); + var userId = Snowflake.Generate(); + + var user = new TestGuildUser + { + Id = userId, + Username = "username", + JoinedAt = DateTimeOffset.UtcNow + }; + + await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(user)); + + // Act + var retrievedUserEntry = await mockDDB.GetUserAsync(userId); + retrievedUserEntry.Should().NotBeNull(); + retrievedUserEntry.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + Date = DateTime.UtcNow, + ModeratorID = Snowflake.Generate(), + Reason = "test reason" + }; + await retrievedUserEntry.UpdateAsync(); + + // Assert + var newlyRetrievedUserEntry = await mockDDB.GetUserAsync(userId); + + newlyRetrievedUserEntry.Should().NotBeNull(); + newlyRetrievedUserEntry.Data.AutoMemberHoldRecord.Should().NotBeNull(); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestGuildUser.cs b/InstarBot.Tests.Common/Models/TestGuildUser.cs index 708620b..142177c 100644 --- a/InstarBot.Tests.Common/Models/TestGuildUser.cs +++ b/InstarBot.Tests.Common/Models/TestGuildUser.cs @@ -178,16 +178,16 @@ public IReadOnlyCollection RoleIds /// public bool Changed { get; private set; } - public string GuildBannerHash => throw new NotImplementedException(); + public string GuildBannerHash { get; set; } = null!; - public string GlobalName => throw new NotImplementedException(); + public string GlobalName { get; set; } = null!; - public string AvatarDecorationHash => throw new NotImplementedException(); + public string AvatarDecorationHash { get; set; } = null!; - public ulong? AvatarDecorationSkuId => throw new NotImplementedException(); - public PrimaryGuild? PrimaryGuild { get; } + public ulong? AvatarDecorationSkuId { get; set; } = null!; + public PrimaryGuild? PrimaryGuild { get; set; } = null!; - public TestGuildUser Clone() + public TestGuildUser Clone() { return (TestGuildUser) MemberwiseClone(); } diff --git a/InstarBot.Tests.Common/Models/TestUser.cs b/InstarBot.Tests.Common/Models/TestUser.cs new file mode 100644 index 0000000..c815a9d --- /dev/null +++ b/InstarBot.Tests.Common/Models/TestUser.cs @@ -0,0 +1,82 @@ +using Discord; +using System.Diagnostics.CodeAnalysis; +using PaxAndromeda.Instar; + +namespace InstarBot.Tests.Models; + +[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] +[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] +public class TestUser : IUser +{ + public TestUser(Snowflake snowflake) + { + Id = snowflake; + CreatedAt = snowflake.Time; + Username = "username"; + } + + public TestUser(TestGuildUser guildUser) + { + Id = guildUser.Id; + CreatedAt = guildUser.CreatedAt; + Mention = guildUser.Mention; + Status = guildUser.Status; + ActiveClients = guildUser.ActiveClients; + Activities = guildUser.Activities; + AvatarId = guildUser.AvatarId; + Discriminator = guildUser.Discriminator; + DiscriminatorValue = guildUser.DiscriminatorValue; + IsBot = guildUser.IsBot; + IsWebhook = guildUser.IsWebhook; + Username = guildUser.Username; + PublicFlags = guildUser.PublicFlags; + AvatarDecorationHash = guildUser.AvatarDecorationHash; + AvatarDecorationSkuId = guildUser.AvatarDecorationSkuId; + GlobalName = guildUser.GlobalName; + PrimaryGuild = guildUser.PrimaryGuild; + } + + public ulong Id { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public string Mention { get; set; } + public UserStatus Status { get; set; } + public IReadOnlyCollection ActiveClients { get; set; } + public IReadOnlyCollection Activities { get; set; } + + public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + { + return string.Empty; + } + + public string GetDefaultAvatarUrl() + { + return string.Empty; + } + + public string GetDisplayAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + { + throw new NotImplementedException(); + } + + public Task CreateDMChannelAsync(RequestOptions? options = null) + { + throw new NotImplementedException(); + } + + public string GetAvatarDecorationUrl() + { + return string.Empty; + } + + public string AvatarId { get; set; } + public string Discriminator { get; set; } + public ushort DiscriminatorValue { get; set; } + public bool IsBot { get; set; } + public bool IsWebhook { get; set; } + public string Username { get; set; } + public UserProperties? PublicFlags { get; set; } + public string GlobalName { get; set; } + public string AvatarDecorationHash { get; set; } + public ulong? AvatarDecorationSkuId { get; set; } + public PrimaryGuild? PrimaryGuild { get; set; } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs index cb6aecd..10bb92d 100644 --- a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs +++ b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs @@ -1,6 +1,8 @@ +using System.Linq.Expressions; using Amazon.DynamoDBv2.DataModel; using Discord; using Moq; +using Moq.Language.Flow; using PaxAndromeda.Instar; using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; @@ -9,63 +11,117 @@ namespace InstarBot.Tests.Services; /// /// A mock implementation of the IInstarDDBService interface for unit testing purposes. -/// This class provides an in-memory storage mechanism to simulate DynamoDB operations. +/// This class just uses Moq in the background to provide mockable behavior. /// -public sealed class MockInstarDDBService : IInstarDDBService +/// +/// Implementation warning: MockInstarDDBService differs from the actual implementation of +/// InstarDDBService. All returned items from are references, +/// meaning any data set on them will persist for future calls. This is different from the +/// concrete implementation, in which you would need to call to +/// persist changes. +/// +public class MockInstarDDBService : IInstarDDBService { - private readonly Dictionary _localData; + private readonly Mock _ddbContextMock = new (); + private readonly Mock _internalMock = new (); public MockInstarDDBService() { - _localData = new Dictionary(); - } + _internalMock.Setup(n => n.GetUserAsync(It.IsAny())) + .ReturnsAsync((InstarDatabaseEntry?) null); + } public MockInstarDDBService(IEnumerable preload) + : this() { - _localData = preload.ToDictionary(n => n.UserID!, n => n); + foreach (var data in preload) { + + _internalMock + .Setup(n => n.GetUserAsync(data.UserID!)) + .ReturnsAsync(new InstarDatabaseEntry(_ddbContextMock.Object, data)); + } } public void Register(InstarUserData data) { - _localData.TryAdd(data.UserID!, data); - } + _internalMock + .Setup(n => n.GetUserAsync(data.UserID!)) + .ReturnsAsync(new InstarDatabaseEntry(_ddbContextMock.Object, data)); + } public Task?> GetUserAsync(Snowflake snowflake) { - if (!_localData.TryGetValue(snowflake, out var data)) - throw new InvalidOperationException("User not found."); - - var ddbContextMock = new Mock(); - - return Task.FromResult(new InstarDatabaseEntry(ddbContextMock.Object, data))!; + return _internalMock.Object.GetUserAsync(snowflake); } - public Task> GetOrCreateUserAsync(IGuildUser user) + public async Task> GetOrCreateUserAsync(IGuildUser user) { - if (!_localData.TryGetValue(user.Id, out var data)) - data = InstarUserData.CreateFrom(user); + // We can't directly set up this method for mocking due to the custom logic here. + // To work around this, we'll first call the same method on the internal mock. If + // it returns a value, we return that. + var mockedResult = await _internalMock.Object.GetOrCreateUserAsync(user); - var ddbContextMock = new Mock(); + // .GetOrCreateUserAsync is expected to never return null in production. However, + // with mocks, it CAN return null if the method was not set up. + // + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (mockedResult is not null) + return mockedResult; - return Task.FromResult(new InstarDatabaseEntry(ddbContextMock.Object, data)); - } + var result = await _internalMock.Object.GetUserAsync(user.Id); - public Task>> GetBatchUsersAsync(IEnumerable snowflakes) - { - return Task.FromResult(GetLocalUsers(snowflakes) - .Select(n => new InstarDatabaseEntry(new Mock().Object, n)).ToList()); - } + if (result is not null) + return result; - private IEnumerable GetLocalUsers(IEnumerable snowflakes) + // Gotta set up the mock + await CreateUserAsync(InstarUserData.CreateFrom(user)); + result = await _internalMock.Object.GetUserAsync(user.Id); + + if (result is null) + Assert.Fail("Failed to correctly set up mocks in MockInstarDDBService"); + + return result; + } + + public async Task>> GetBatchUsersAsync(IEnumerable snowflakes) { - foreach (var snowflake in snowflakes) - if (_localData.TryGetValue(snowflake, out var data)) - yield return data; + return await GetLocalUsersAsync(snowflakes).ToListAsync(); } + private async IAsyncEnumerable> GetLocalUsersAsync(IEnumerable snowflakes) + { + foreach (var snowflake in snowflakes) + { + var data = await _internalMock.Object.GetUserAsync(snowflake); + + if (data is not null) + yield return data; + } + } + public Task CreateUserAsync(InstarUserData data) { - _localData.TryAdd(data.UserID!, data); - return Task.CompletedTask; - } + _internalMock + .Setup(n => n.GetUserAsync(data.UserID!)) + .ReturnsAsync(new InstarDatabaseEntry(_ddbContextMock.Object, data)); + + return Task.CompletedTask; + } + + /// + /// Configures a setup for the specified expression on the mocked interface, allowing + /// control over the behavior of the mock for the given member. + /// + /// Use this method to define expectations or return values for specific members of when using mocking frameworks. The returned setup object allows chaining of additional + /// configuration methods, such as specifying return values or verifying calls. + /// The type of the value returned by the member specified in the expression. + /// An expression that identifies the member of to set up. Typically, a lambda + /// expression specifying a method or property to mock. + /// An instance that can be used to further configure the behavior of + /// the mock for the specified member. + public ISetup Setup(Expression> expression) + { + return _internalMock.Setup(expression); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/TestUtilities.cs b/InstarBot.Tests.Common/TestUtilities.cs index ad3799f..9fdcace 100644 --- a/InstarBot.Tests.Common/TestUtilities.cs +++ b/InstarBot.Tests.Common/TestUtilities.cs @@ -1,4 +1,5 @@ using System.Linq.Expressions; +using System.Text.RegularExpressions; using Discord; using Discord.Interactions; using FluentAssertions; @@ -63,26 +64,105 @@ public static IServiceProvider GetServices() sc.AddSingleton(GetDynamicConfiguration()); return sc.BuildServiceProvider(); - } - - /// - /// Verifies that the command responded to the user with the correct . - /// - /// A mockup of the command. - /// The message to check for. - /// A flag indicating whether the message should be ephemeral. - /// The type of command. Must implement . - public static void VerifyMessage(Mock command, string message, bool ephemeral = false) - where T : BaseCommand - { - command.Protected().Verify( - "RespondAsync", Times.Once(), - message, ItExpr.IsAny(), - false, ephemeral, ItExpr.IsAny(), ItExpr.IsAny(), - ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); - } - - public static IDiscordService SetupDiscordService(TestContext context = null!) + } + + /// + /// Verifies that the command responded to the user with the correct . + /// + /// A mockup of the command. + /// The string format to check called messages against. + /// A flag indicating whether the message should be ephemeral. + /// A flag indicating whether partial matches are acceptable. + /// The type of command. Must implement . + public static void VerifyMessage(Mock command, string format, bool ephemeral = false, bool partial = false) + where T : BaseCommand + { + command.Protected().Verify( + "RespondAsync", + Times.Once(), + ItExpr.Is( + n => MatchesFormat(n, format, partial)), // text + ItExpr.IsAny(), // embeds + false, // isTTS + ephemeral, // ephemeral + ItExpr.IsAny(), // allowedMentions + ItExpr.IsAny(), // options + ItExpr.IsAny(), // components + ItExpr.IsAny(), // embed + ItExpr.IsAny(), // pollProperties + ItExpr.IsAny() // messageFlags + ); + } + + /// + /// Verifies that the command responded to the user with an embed that satisfies the specified . + /// + /// The type of command. Must implement . + /// A mockup of the command. + /// An instance to verify against. + /// An optional message format, if present. Defaults to null. + /// An optional flag indicating whether the message is expected to be ephemeral. Defaults to false. + /// An optional flag indicating whether partial matches are acceptable. Defaults to false. + public static void VerifyEmbed(Mock command, EmbedVerifier verifier, string? format = null, bool ephemeral = false, bool partial = false) + where T : BaseCommand + { + var msgRef = format is null + ? ItExpr.IsNull() + : ItExpr.Is(n => MatchesFormat(n, format, partial)); + + command.Protected().Verify( + "RespondAsync", + Times.Once(), + msgRef, // text + ItExpr.IsNull(), // embeds + false, // isTTS + ephemeral, // ephemeral + ItExpr.IsAny(), // allowedMentions + ItExpr.IsNull(), // options + ItExpr.IsNull(), // components + ItExpr.Is(e => verifier.Verify(e)), // embed + ItExpr.IsNull(), // pollProperties + ItExpr.IsAny() // messageFlags + ); + } + + public static void VerifyChannelMessage(Mock channel, string format, bool ephemeral = false, bool partial = false) + where T : class, ITextChannel + { + channel.Verify(c => c.SendMessageAsync( + It.Is(s => MatchesFormat(s, format, partial)), + false, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )); + } + + public static void VerifyChannelEmbed(Mock channel, EmbedVerifier verifier, string format, bool ephemeral = false, bool partial = false) + where T : class, ITextChannel + { + channel.Verify(c => c.SendMessageAsync( + It.Is(n => MatchesFormat(n, format, partial)), + false, + It.Is(e => verifier.Verify(e)), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )); + } + + public static IDiscordService SetupDiscordService(TestContext context = null!) => new MockDiscordService(SetupGuild(context)); public static IGaiusAPIService SetupGaiusAPIService(TestContext context = null!) @@ -250,4 +330,27 @@ public static void SetupLogging() .CreateLogger(); Log.Warning("Logging is enabled for this unit test."); } + + /// + /// Returns true if matches the format specified in . + /// + /// The text to validate. + /// The format to check the text against. + /// Allows for partial matching. + /// True if the matches the format in . + public static bool MatchesFormat(string text, string format, bool partial = false) + { + string formatRegex = Regex.Escape(format); + + if (!partial) + formatRegex = $"^{formatRegex}$"; + + // We cannot simply replace the escaped template variables, as that would escape the braces. + formatRegex = formatRegex.Replace("\\{", "{").Replace("\\}", "}"); + + // Replaces any template variable (e.g., {0}, {name}, etc.) with a regex wildcard that matches any text. + formatRegex = Regex.Replace(formatRegex, "{.+?}", "(?:.+?)"); + + return Regex.IsMatch(text, formatRegex); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj.DotSettings b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj.DotSettings index cac3159..7121aa7 100644 --- a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj.DotSettings +++ b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj.DotSettings @@ -1,6 +1,3 @@ - - True \ No newline at end of file + + No + True \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs new file mode 100644 index 0000000..30259be --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs @@ -0,0 +1,224 @@ +using Discord; +using FluentAssertions; +using InstarBot.Tests.Models; +using InstarBot.Tests.Services; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Metrics; +using Xunit; + +namespace InstarBot.Tests.Integration.Interactions; + +public static class AutoMemberSystemCommandTests +{ + private const ulong NewMemberRole = 796052052433698817ul; + private const ulong MemberRole = 793611808372031499ul; + + private static async Task Setup(bool setupAMH = false) + { + TestUtilities.SetupLogging(); + + // This is going to be annoying + var userID = Snowflake.Generate(); + var modID = Snowflake.Generate(); + + var mockDDB = new MockInstarDDBService(); + var mockMetrics = new MockMetricService(); + + var user = new TestGuildUser + { + Id = userID, + Username = "username", + JoinedAt = DateTimeOffset.UtcNow, + RoleIds = [ NewMemberRole ] + }; + + var mod = new TestGuildUser + { + Id = modID, + Username = "mod_username", + JoinedAt = DateTimeOffset.UtcNow, + RoleIds = [MemberRole] + }; + + await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(user)); + await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(mod)); + + if (setupAMH) + { + var ddbRecord = await mockDDB.GetUserAsync(userID); + ddbRecord.Should().NotBeNull(); + ddbRecord.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + Date = DateTime.UtcNow, + ModeratorID = modID, + Reason = "test reason" + }; + await ddbRecord.UpdateAsync(); + } + + var commandMock = TestUtilities.SetupCommandMock( + () => new AutoMemberHoldCommand(mockDDB, TestUtilities.GetDynamicConfiguration(), mockMetrics), + new TestContext + { + UserID = modID + }); + + return new Context(mockDDB, mockMetrics, user, mod, commandMock); + } + + [Fact] + public static async Task HoldMember_WithValidUserAndReason_ShouldCreateRecord() + { + // Arrange + var ctx = await Setup(); + + // Act + await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Success, ephemeral: true); + + var record = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + record.Should().NotBeNull(); + record.Data.AutoMemberHoldRecord.Should().NotBeNull(); + record.Data.AutoMemberHoldRecord.ModeratorID.ID.Should().Be(ctx.Moderator.Id); + record.Data.AutoMemberHoldRecord.Reason.Should().Be("Test reason"); + } + + [Fact] + public static async Task HoldMember_WithNonGuildUser_ShouldGiveError() + { + // Arrange + var ctx = await Setup(); + + // Act + await ctx.Command.Object.HoldMember(new TestUser(ctx.TargetUser), "Test reason"); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_NotGuildMember, ephemeral: true); + } + + [Fact] + public static async Task HoldMember_AlreadyAMHed_ShouldGiveError() + { + // Arrange + var ctx = await Setup(true); + + // Act + await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_AMHAlreadyExists, ephemeral: true); + } + + [Fact] + public static async Task HoldMember_AlreadyMember_ShouldGiveError() + { + // Arrange + var ctx = await Setup(); + await ctx.TargetUser.AddRoleAsync(MemberRole); + + // Act + await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_AlreadyMember, ephemeral: true); + } + + [Fact] + public static async Task HoldMember_WithDynamoDBError_ShouldRespondWithError() + { + // Arrange + var ctx = await Setup(); + + // Act + ctx.DDBService + .Setup(n => n.GetOrCreateUserAsync(It.IsAny())) + .ThrowsAsync(new BadStateException()); + + await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_Unexpected, ephemeral: true); + + var record = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + record.Should().NotBeNull(); + record.Data.AutoMemberHoldRecord.Should().BeNull(); + ctx.Metrics.GetMetricValues(Metric.AMS_AMHFailures).Sum().Should().BeGreaterThan(0); + } + + [Fact] + public static async Task UnholdMember_WithValidUser_ShouldRemoveAMH() + { + // Arrange + var ctx = await Setup(true); + + // Act + await ctx.Command.Object.UnholdMember(ctx.TargetUser); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Success, ephemeral: true); + + var afterRecord = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + afterRecord.Should().NotBeNull(); + afterRecord.Data.AutoMemberHoldRecord.Should().BeNull(); + } + + [Fact] + public static async Task UnholdMember_WithValidUserNoActiveHold_ShouldReturnError() + { + // Arrange + var ctx = await Setup(); + + // Act + await ctx.Command.Object.UnholdMember(ctx.TargetUser); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Error_NoActiveHold, ephemeral: true); + } + + [Fact] + public static async Task UnholdMember_WithNonGuildUser_ShouldReturnError() + { + // Arrange + var ctx = await Setup(); + + // Act + await ctx.Command.Object.UnholdMember(new TestUser(ctx.TargetUser)); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Error_NotGuildMember, ephemeral: true); + } + + [Fact] + public static async Task UnholdMember_WithDynamoError_ShouldReturnError() + { + // Arrange + var ctx = await Setup(true); + + ctx.DDBService + .Setup(n => n.GetOrCreateUserAsync(It.IsAny())) + .ThrowsAsync(new BadStateException()); + + // Act + await ctx.Command.Object.UnholdMember(ctx.TargetUser); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Error_Unexpected, ephemeral: true); + + // Sanity check: if the DDB errors out, the AMH should still be there + var afterRecord = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + afterRecord.Should().NotBeNull(); + afterRecord.Data.AutoMemberHoldRecord.Should().NotBeNull(); + } + + private record Context( + MockInstarDDBService DDBService, + MockMetricService Metrics, + TestGuildUser TargetUser, + TestGuildUser Moderator, + Mock Command); +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs index dde6b68..6fd33dc 100644 --- a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs @@ -1,169 +1,328 @@ using Discord; +using FluentAssertions; +using InstarBot.Tests.Models; using InstarBot.Tests.Services; using Moq; -using Moq.Protected; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Services; using Xunit; namespace InstarBot.Tests.Integration.Interactions; public static class CheckEligibilityCommandTests { + private const ulong MemberRole = 793611808372031499ul; + private const ulong NewMemberRole = 796052052433698817ul; - private static Mock SetupCommandMock(CheckEligibilityCommandTestContext context) + private static async Task> SetupCommandMock(CheckEligibilityCommandTestContext context, Action>? setupMocks = null) { + TestUtilities.SetupLogging(); + var mockAMS = new Mock(); mockAMS.Setup(n => n.CheckEligibility(It.IsAny(), It.IsAny())).Returns(context.Eligibility); + var userId = Snowflake.Generate(); + + var mockDDB = new MockInstarDDBService(); + var user = new TestGuildUser + { + Id = userId, + Username = "username", + JoinedAt = DateTimeOffset.Now, + RoleIds = context.Roles + }; - List userRoles = [ - // from Instar.dynamic.test.debug.conf.json: member and new member role, respectively - context.IsMember ? 793611808372031499ul : 796052052433698817ul - ]; + await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(user)); if (context.IsAMH) { - // from Instar.dynamic.test.debug.conf.json - userRoles.Add(966434762032054282); + var ddbUser = await mockDDB.GetUserAsync(userId); + + ddbUser.Should().NotBeNull(); + ddbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + Date = DateTime.UtcNow, + ModeratorID = Snowflake.Generate(), + Reason = "Testing" + }; + + await ddbUser.UpdateAsync(); } var commandMock = TestUtilities.SetupCommandMock( - () => new CheckEligibilityCommand(TestUtilities.GetDynamicConfiguration(), mockAMS.Object, new MockMetricService()), + () => new CheckEligibilityCommand(TestUtilities.GetDynamicConfiguration(), mockAMS.Object, mockDDB, new MockMetricService()), new TestContext { - UserRoles = userRoles + UserID = userId, + UserRoles = context.Roles.Select(n => new Snowflake(n)).ToList() }); + context.DDB = mockDDB; + context.User = user; + return commandMock; } - private static void VerifyResponse(Mock command, string expectedString) + private static EmbedVerifier.VerifierBuilder CreateVerifier() { - command.Protected().Verify( - "RespondAsync", - Times.Once(), - expectedString, // text - ItExpr.IsNull(), // embeds - false, // isTTS - true, // ephemeral - ItExpr.IsNull(), // allowedMentions - ItExpr.IsNull(), // options - ItExpr.IsNull(), // components - ItExpr.IsAny(), // embed - ItExpr.IsAny(), // pollProperties - ItExpr.IsAny() // messageFlags - ); + return EmbedVerifier.Builder() + .WithTitle(Strings.Command_CheckEligibility_EmbedTitle) + .WithFooterText(Strings.Embed_AMS_Footer); } - private static void VerifyResponseEmbed(Mock command, CheckEligibilityCommandTestContext ctx) + + [Fact] + public static async Task CheckEligibilityCommand_WithExistingMember_ShouldEmitValidMessage() { - // Little more convoluted to verify embed content - command.Protected().Verify( - "RespondAsync", - Times.Once(), - ItExpr.IsNull(), // text - ItExpr.IsNull(), // embeds - false, // isTTS - true, // ephemeral - ItExpr.IsNull(), // allowedMentions - ItExpr.IsNull(), // options - ItExpr.IsNull(), // components - ItExpr.Is(e => e.Description.Contains(ctx.DescriptionPattern) && e.Description.Contains(ctx.DescriptionPattern2) && - e.Fields.Any(n => n.Value.Contains(ctx.MissingItemPattern)) - ), // embed - ItExpr.IsNull(), // pollProperties - ItExpr.IsAny() // messageFlags - ); + // Arrange + var ctx = new CheckEligibilityCommandTestContext(false, [ MemberRole ], MembershipEligibility.Eligible); + var mock = await SetupCommandMock(ctx); + + // Act + await mock.Object.CheckEligibility(); + + // Assert + TestUtilities.VerifyMessage(mock, Strings.Command_CheckEligibility_Error_AlreadyMember, true); } [Fact] - public static async Task CheckEligibilityCommand_WithExistingMember_ShouldEmitValidMessage() + public static async Task CheckEligibilityCommand_NoMemberRoles_ShouldEmitValidErrorMessage() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, true, MembershipEligibility.Eligible); - var mock = SetupCommandMock(ctx); + var ctx = new CheckEligibilityCommandTestContext(false, [], MembershipEligibility.Eligible); + var mock = await SetupCommandMock(ctx); // Act await mock.Object.CheckEligibility(); // Assert - VerifyResponse(mock, "You are already a member!"); + TestUtilities.VerifyMessage(mock, Strings.Command_CheckEligibility_Error_NoMemberRoles, true); + } + + [Fact] + public static async Task CheckEligibilityCommand_Eligible_ShouldEmitValidMessage() + { + // Arrange + var verifier = CreateVerifier() + .WithFlags(EmbedVerifierMatchFlags.PartialDescription) + .WithDescription(Strings.Command_CheckEligibility_MessagesEligibility) + .Build(); + + var ctx = new CheckEligibilityCommandTestContext( + false, + [ NewMemberRole ], + MembershipEligibility.Eligible); + + + var mock = await SetupCommandMock(ctx); + + // Act + await mock.Object.CheckEligibility(); + + // Assert + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); } [Fact] public static async Task CheckEligibilityCommand_WithNewMemberAMH_ShouldEmitValidMessage() { // Arrange + var verifier = CreateVerifier() + .WithFlags(EmbedVerifierMatchFlags.PartialDescription) + .WithDescription(Strings.Command_CheckEligibility_AMH_MembershipWithheld) + .WithField(Strings.Command_CheckEligibility_AMH_Why) + .WithField(Strings.Command_CheckEligibility_AMH_WhatToDo) + .WithField(Strings.Command_CheckEligibility_AMH_ContactStaff) + .Build(); + var ctx = new CheckEligibilityCommandTestContext( true, - false, - MembershipEligibility.Eligible, - "Your membership is currently on hold", - MissingItemPattern: "The staff will override an administrative hold"); + [ NewMemberRole ], + MembershipEligibility.Eligible); + - var mock = SetupCommandMock(ctx); + var mock = await SetupCommandMock(ctx); // Act await mock.Object.CheckEligibility(); // Assert - VerifyResponseEmbed(mock, ctx); + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + } + + [Fact] + public static async Task CheckEligibilityCommand_DDBError_ShouldEmitValidMessage() + { + // Arrange + var verifier = CreateVerifier() + .WithFlags(EmbedVerifierMatchFlags.PartialDescription) + .WithDescription(Strings.Command_CheckEligibility_MessagesEligibility) + .Build(); + + var ctx = new CheckEligibilityCommandTestContext( + true, + [ NewMemberRole ], + MembershipEligibility.Eligible); + + var mock = await SetupCommandMock(ctx); + + ctx.DDB.Setup(n => n.GetUserAsync(It.IsAny())).Throws(); + + // Act + await mock.Object.CheckEligibility(); + + // Assert + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); } [Theory] - [InlineData(MembershipEligibility.MissingRoles, "You are missing an age role.")] - [InlineData(MembershipEligibility.MissingIntroduction, "You have not posted an introduction in")] - [InlineData(MembershipEligibility.TooYoung, "You have not been on the server for")] - [InlineData(MembershipEligibility.PunishmentReceived, "You have received a warning or moderator action.")] - [InlineData(MembershipEligibility.NotEnoughMessages, "messages in the past")] - public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMessage(MembershipEligibility eligibility, string pattern) + [InlineData(MembershipEligibility.MissingRoles)] + [InlineData(MembershipEligibility.MissingIntroduction)] + [InlineData(MembershipEligibility.TooYoung)] + [InlineData(MembershipEligibility.PunishmentReceived)] + [InlineData(MembershipEligibility.NotEnoughMessages)] + public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMessage(MembershipEligibility eligibility) { // Arrange - string sectionHeader = eligibility switch + var eligibilityMap = new Dictionary { - MembershipEligibility.MissingRoles => "Roles", - MembershipEligibility.MissingIntroduction => "Introduction", - MembershipEligibility.TooYoung => "Join Age", - MembershipEligibility.PunishmentReceived => "Mod Actions", - MembershipEligibility.NotEnoughMessages => "Messages", - _ => "" + { MembershipEligibility.MissingRoles, Strings.Command_CheckEligibility_MessagesEligibility }, + { MembershipEligibility.MissingIntroduction, Strings.Command_CheckEligibility_IntroductionEligibility }, + { MembershipEligibility.TooYoung, Strings.Command_CheckEligibility_JoinAgeEligibility }, + { MembershipEligibility.PunishmentReceived, Strings.Command_CheckEligibility_ModActionsEligibility }, + { MembershipEligibility.NotEnoughMessages, Strings.Command_CheckEligibility_MessagesEligibility }, }; - // Cheeky way to get another section header - string anotherSectionHeader = (MembershipEligibility) ((int)eligibility << 1) switch + var fieldMap = new Dictionary { - MembershipEligibility.MissingRoles => "Roles", - MembershipEligibility.MissingIntroduction => "Introduction", - MembershipEligibility.TooYoung => "Join Age", - MembershipEligibility.PunishmentReceived => "Mod Actions", - MembershipEligibility.NotEnoughMessages => "Messages", - _ => "Roles" + { MembershipEligibility.MissingRoles, Strings.Command_CheckEligibility_MissingItem_Role }, + { MembershipEligibility.MissingIntroduction, Strings.Command_CheckEligibility_MissingItem_Introduction }, + { MembershipEligibility.TooYoung, Strings.Command_CheckEligibility_MissingItem_TooYoung }, + { MembershipEligibility.PunishmentReceived, Strings.Command_CheckEligibility_MissingItem_PunishmentReceived }, + { MembershipEligibility.NotEnoughMessages, Strings.Command_CheckEligibility_MissingItem_Messages }, }; + var testDescription = eligibilityMap.GetValueOrDefault(eligibility, string.Empty); + //var nontestFieldFormat = eligibilityMap.GetValueOrDefault(eligibility, eligibilityMap[MembershipEligibility.MissingRoles]); + var testFieldMap = fieldMap.GetValueOrDefault(eligibility, string.Empty); + + var verifier = CreateVerifier() + .WithFlags(EmbedVerifierMatchFlags.PartialDescription) + .WithDescription(testDescription) + .WithField(testFieldMap, true) + .Build(); + var ctx = new CheckEligibilityCommandTestContext( false, - false, - MembershipEligibility.NotEligible | eligibility, - $":x: **{sectionHeader}**", - $":white_check_mark: **{anotherSectionHeader}", - pattern); + [ NewMemberRole ], + MembershipEligibility.NotEligible | eligibility); - var mock = SetupCommandMock(ctx); + var mock = await SetupCommandMock(ctx); // Act await mock.Object.CheckEligibility(); // Assert - VerifyResponseEmbed(mock, ctx); + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + } + + [Fact] + public static async Task CheckOtherEligibility_WithEligibleMember_ShouldEmitValidEmbed() + { + // Arrange + var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.Eligible); + var mock = await SetupCommandMock(ctx); + + ctx.User.Should().NotBeNull(); + var verifier = CreateVerifier() + .WithDescription(Strings.Command_Eligibility_EligibleText) + .WithAuthorName(ctx.User.Username) + .Build(); + + // Act + await mock.Object.CheckOtherEligibility(ctx.User); + + // Assert + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + } + + [Fact] + public static async Task CheckOtherEligibility_WithIneligibleMember_ShouldEmitValidEmbed() + { + // Arrange + var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.NotEligible | MembershipEligibility.MissingRoles); + var mock = await SetupCommandMock(ctx); + + ctx.User.Should().NotBeNull(); + var verifier = CreateVerifier() + .WithDescription(Strings.Command_Eligibility_IneligibleText) + .WithAuthorName(ctx.User.Username) + .WithField(Strings.Command_Eligibility_Section_Requirements, Strings.Command_CheckEligibility_RolesEligibility, true) + .Build(); + + // Act + await mock.Object.CheckOtherEligibility(ctx.User); + + // Assert + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + } + + [Fact] + public static async Task CheckOtherEligibility_WithAMHedMember_ShouldEmitValidEmbed() + { + // Arrange + var ctx = new CheckEligibilityCommandTestContext(true, [ NewMemberRole ], MembershipEligibility.NotEligible | MembershipEligibility.MissingRoles); + var mock = await SetupCommandMock(ctx); + + ctx.User.Should().NotBeNull(); + var verifier = CreateVerifier() + .WithDescription(Strings.Command_Eligibility_IneligibleText) + .WithAuthorName(ctx.User.Username) + .WithField(Strings.Command_Eligibility_Section_Hold, Strings.Command_Eligibility_HoldFormat) + .Build(); + + // Act + await mock.Object.CheckOtherEligibility(ctx.User); + + // Assert + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + } + + [Fact] + public static async Task CheckOtherEligibility_WithDynamoError_ShouldEmitValidEmbed() + { + // Arrange + var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.NotEligible | MembershipEligibility.MissingRoles); + var mock = await SetupCommandMock(ctx); + + ctx.DDB.Setup(n => n.GetUserAsync(It.IsAny())) + .Throws(new BadStateException("Bad state")); + + + ctx.User.Should().NotBeNull(); + var verifier = CreateVerifier() + .WithDescription(Strings.Command_Eligibility_IneligibleText) + .WithAuthorName(ctx.User.Username) + .WithField(Strings.Command_Eligibility_Section_Requirements, Strings.Command_CheckEligibility_RolesEligibility, true) + .WithField(Strings.Command_Eligibility_Section_AmbiguousHold, Strings.Command_Eligibility_Error_AmbiguousHold) + .Build(); + + // Act + await mock.Object.CheckOtherEligibility(ctx.User); + + // Assert + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); } private record CheckEligibilityCommandTestContext( bool IsAMH, - bool IsMember, - MembershipEligibility Eligibility, - string DescriptionPattern = "", - string DescriptionPattern2 = "", - string MissingItemPattern = ""); + List Roles, + MembershipEligibility Eligibility) + { + internal IGuildUser? User { get; set; } + public MockInstarDDBService DDB { get ; set ; } + } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index dbdb3b4..1bb380c 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -1,4 +1,5 @@ using Discord; +using InstarBot.Tests.Models; using InstarBot.Tests.Services; using Moq; using Moq.Protected; @@ -10,6 +11,8 @@ namespace InstarBot.Tests.Integration.Interactions; public static class PageCommandTests { + private const string TestReason = "Test reason for paging"; + private static async Task> SetupCommandMock(PageCommandTestContext context) { // Treat the Test page target as a regular non-staff user on the server @@ -41,40 +44,54 @@ private static async Task GetTeamLead(PageTarget pageTarget) private static async Task VerifyPageEmbedEmitted(Mock command, PageCommandTestContext context) { - var pageTarget = context.PageTarget; - - string expectedString; - - if (context.PagingTeamLeader) - expectedString = $"<@{await GetTeamLead(pageTarget)}>"; - else - switch (pageTarget) - { - case PageTarget.All: - expectedString = string.Join(' ', - await TestUtilities.GetTeams(PageTarget.All).Select(n => Snowflake.GetMention(() => n.ID)) - .ToArrayAsync()); - break; - case PageTarget.Test: - expectedString = "This is a __**TEST**__ page."; - break; - default: - var team = await TestUtilities.GetTeams(pageTarget).FirstAsync(); - expectedString = Snowflake.GetMention(() => team.ID); - break; - } - - command.Protected().Verify( - "RespondAsync", Times.Once(), - expectedString, ItExpr.IsNull(), - false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); + var verifier = EmbedVerifier.Builder() + .WithFooterText(Strings.Embed_Page_Footer) + .WithDescription("```{0}```"); + + if (context.TargetUser is not null) + verifier = verifier.WithField( + "User", + $"<@{context.TargetUser.Id}>"); + + if (context.TargetChannel is not null) + verifier = verifier.WithField( + "Channel", + $"<#{context.TargetChannel.Id}>"); + + if (context.Message is not null) + verifier = verifier.WithField( + "Message", + $"{context.Message}"); + + string messageFormat; + if (context.PagingTeamLeader) + messageFormat = "<@{0}>"; + else + switch (context.PageTarget) + { + case PageTarget.All: + messageFormat = string.Join(' ', + await TestUtilities.GetTeams(PageTarget.All).Select(n => Snowflake.GetMention(() => n.ID)) + .ToArrayAsync()); + break; + case PageTarget.Test: + messageFormat = Strings.Command_Page_TestPageMessage; + break; + default: + var team = await TestUtilities.GetTeams(context.PageTarget).FirstAsync(); + messageFormat = Snowflake.GetMention(() => team.ID); + break; + } + + + TestUtilities.VerifyEmbed(command, verifier.Build(), messageFormat); } [Fact(DisplayName = "User should be able to page when authorized")] public static async Task PageCommand_Authorized_WhenPagingTeam_ShouldPageCorrectly() { // Arrange + TestUtilities.SetupLogging(); var context = new PageCommandTestContext( PageTarget.Owner, PageTarget.Moderator, @@ -84,32 +101,54 @@ public static async Task PageCommand_Authorized_WhenPagingTeam_ShouldPageCorrect var command = await SetupCommandMock(context); // Act - await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); - - // Assert - await VerifyPageEmbedEmitted(command, context); - } - - [Fact(DisplayName = "User should be able to page a team's teamleader")] - public static async Task PageCommand_Authorized_WhenPagingTeamLeader_ShouldPageCorrectly() - { - // Arrange - var context = new PageCommandTestContext( - PageTarget.Owner, - PageTarget.Moderator, - true - ); - - var command = await SetupCommandMock(context); - - // Act - await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert await VerifyPageEmbedEmitted(command, context); - } - - [Fact(DisplayName = "Any staff member should be able to use the Test page")] + } + + [Fact(DisplayName = "User should be able to page a team's teamleader")] + public static async Task PageCommand_Authorized_WhenPagingTeamLeader_ShouldPageCorrectly() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Owner, + PageTarget.Moderator, + true + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); + + // Assert + await VerifyPageEmbedEmitted(command, context); + } + + [Fact(DisplayName = "User should be able to page a with user, channel and message")] + public static async Task PageCommand_Authorized_WhenPagingWithData_ShouldPageCorrectly() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Owner, + PageTarget.Moderator, + true, + TargetUser: new TestUser(Snowflake.Generate()), + TargetChannel: new TestChannel(Snowflake.Generate()), + Message: "" + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, context.Message!, context.TargetUser, context.TargetChannel); + + // Assert + await VerifyPageEmbedEmitted(command, context); + } + + [Fact(DisplayName = "Any staff member should be able to use the Test page")] public static async Task PageCommand_AnyStaffMember_WhenPagingTest_ShouldPageCorrectly() { var targets = Enum.GetValues().Except([PageTarget.All, PageTarget.Test]); @@ -126,7 +165,7 @@ public static async Task PageCommand_AnyStaffMember_WhenPagingTest_ShouldPageCor var command = await SetupCommandMock(context); // Act - await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert @@ -147,7 +186,7 @@ public static async Task PageCommand_Authorized_WhenOwnerPagingAll_ShouldPageCor var command = await SetupCommandMock(context); // Act - await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert await VerifyPageEmbedEmitted(command, context); @@ -166,12 +205,12 @@ public static async Task PageCommand_Authorized_WhenOwnerPagingAllTeamLead_Shoul var command = await SetupCommandMock(context); // Act - await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert TestUtilities.VerifyMessage( command, - "Failed to send page. The 'All' team does not have a teamleader. If intended to page the owner, please select the Owner as the team.", + Strings.Command_Page_Error_NoAllTeamlead, true ); } @@ -189,12 +228,12 @@ public static async Task PageCommand_Unauthorized_WhenAttemptingToPage_ShouldFai var command = await SetupCommandMock(context); // Act - await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert TestUtilities.VerifyMessage( command, - "You are not authorized to use this command.", + Strings.Command_Page_Error_NotAuthorized, true ); } @@ -212,14 +251,20 @@ public static async Task PageCommand_Authorized_WhenHelperAttemptsToPageAll_Shou var command = await SetupCommandMock(context); // Act - await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert TestUtilities.VerifyMessage( command, - "You are not authorized to send a page to the entire staff team.", + Strings.Command_Page_Error_FullTeamNotAuthorized, true ); } - private record PageCommandTestContext(PageTarget UserTeamID, PageTarget PageTarget, bool PagingTeamLeader); + private record PageCommandTestContext( + PageTarget UserTeamID, + PageTarget PageTarget, + bool PagingTeamLeader, + IUser? TargetUser = null, + IChannel? TargetChannel = null, + string? Message = null); } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs index eed8d1a..c71b1bf 100644 --- a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -23,6 +23,15 @@ public static async Task ReportUser_WhenReportingNormally_ShouldNotifyStaff() var (command, interactionContext, channelMock) = SetupMocks(context); + var verifier = EmbedVerifier.Builder() + .WithFooterText(Strings.Embed_UserReport_Footer) + .WithField("Reason", $"```{context.Reason}```") + .WithField("Message Content", "{0}") + .WithField("User", "<@{0}>") + .WithField("Channel", "<#{0}>") + .WithField("Reported By", "<@{0}>") + .Build(); + // Act await command.Object.HandleCommand(interactionContext.Object); await command.Object.ModalResponse(new ReportMessageModal @@ -31,13 +40,11 @@ await command.Object.ModalResponse(new ReportMessageModal }); // Assert - TestUtilities.VerifyMessage(command, "Your report has been sent.", true); + TestUtilities.VerifyMessage(command, Strings.Command_ReportUser_ReportSent, true); + + + TestUtilities.VerifyChannelEmbed(channelMock, verifier, "{0}"); - channelMock.Verify(n => n.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny())); context.ResultEmbed.Should().NotBeNull(); var embed = context.ResultEmbed; @@ -68,7 +75,7 @@ await command.Object.ModalResponse(new ReportMessageModal }); // Assert - TestUtilities.VerifyMessage(command, "Report expired. Please try again.", true); + TestUtilities.VerifyMessage(command, Strings.Command_ReportUser_ReportExpired, true); } private static (Mock, Mock, Mock) SetupMocks(ReportContext context) diff --git a/InstarBot/Commands/AutoMemberHoldCommand.cs b/InstarBot/Commands/AutoMemberHoldCommand.cs index 5cc94e6..daa858f 100644 --- a/InstarBot/Commands/AutoMemberHoldCommand.cs +++ b/InstarBot/Commands/AutoMemberHoldCommand.cs @@ -33,7 +33,8 @@ string reason { if (user is not IGuildUser guildUser) { - await RespondAsync($"Error while attempting to withhold membership for <@{user.Id}>: User is not a guild member.", ephemeral: true); + + await RespondAsync(string.Format(Strings.Command_AutoMemberHold_Error_NotGuildMember, user.Id), ephemeral: true); return; } @@ -41,12 +42,18 @@ string reason if (guildUser.RoleIds.Contains(config.MemberRoleID)) { - await RespondAsync($"Error while attempting to withhold membership for <@{user.Id}>: User is already a member.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_AutoMemberHold_Error_AlreadyMember, user.Id), ephemeral: true); return; } var dbUser = await ddbService.GetOrCreateUserAsync(guildUser); + if (dbUser.Data.AutoMemberHoldRecord is not null) + { + await RespondAsync(string.Format(Strings.Command_AutoMemberHold_Error_AMHAlreadyExists, user.Id), ephemeral: true); + return; + } + dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord { ModeratorID = modId, @@ -56,7 +63,7 @@ string reason await dbUser.UpdateAsync(); // TODO: configurable duration? - await RespondAsync($"Membership for user <@{user.Id}> has been withheld. Staff will be notified in one week to review.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_AutoMemberHold_Success, user.Id), ephemeral: true); } catch (Exception ex) { await metricService.Emit(Metric.AMS_AMHFailures, 1); @@ -65,7 +72,7 @@ string reason try { // It is entirely possible that RespondAsync threw this error. - await RespondAsync($"Error while attempting to withhold membership for <@{user.Id}>: An unexpected error has occurred while configuring the AMH.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_AutoMemberHold_Error_Unexpected, user.Id), ephemeral: true); } catch { // swallow the exception @@ -85,7 +92,7 @@ IUser user if (user is not IGuildUser guildUser) { - await RespondAsync($"Error while attempting to remove auto member hold for <@{user.Id}>: User is not a guild member.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_AutoMemberUnhold_Error_NotGuildMember, user.Id), ephemeral: true); return; } @@ -94,14 +101,14 @@ IUser user var dbUser = await ddbService.GetOrCreateUserAsync(guildUser); if (dbUser.Data.AutoMemberHoldRecord is null) { - await RespondAsync($"Error while attempting to remove auto member hold for <@{user.Id}>: User does not have an active auto member hold.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_AutoMemberUnhold_Error_NoActiveHold, user.Id), ephemeral: true); return; } dbUser.Data.AutoMemberHoldRecord = null; await dbUser.UpdateAsync(); - await RespondAsync($"Auto member hold for user <@{user.Id}> has been removed.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_AutoMemberUnhold_Success, user.Id), ephemeral: true); } catch (Exception ex) { @@ -110,7 +117,7 @@ IUser user try { - await RespondAsync($"Error while attempting to remove auto member hold for <@{user.Id}>: An unexpected error has occurred while removing the AMH.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_AutoMemberUnhold_Error_Unexpected, user.Id), ephemeral: true); } catch { diff --git a/InstarBot/Commands/CheckEligibilityCommand.cs b/InstarBot/Commands/CheckEligibilityCommand.cs index 2caac84..d429cb3 100644 --- a/InstarBot/Commands/CheckEligibilityCommand.cs +++ b/InstarBot/Commands/CheckEligibilityCommand.cs @@ -1,12 +1,12 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text; using Discord; using Discord.Interactions; using JetBrains.Annotations; -using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Embeds; using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Serilog; +using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Commands; @@ -27,32 +27,44 @@ public async Task CheckEligibility() if (Context.User is null) { Log.Error("Checking eligibility, but Context.User is null"); - await RespondAsync("An internal error has occurred. Please try again later.", ephemeral: true); + await RespondAsync(Strings.Command_CheckEligibility_Error_Internal, ephemeral: true); } if (!Context.User!.RoleIds.Contains(config.MemberRoleID) && !Context.User!.RoleIds.Contains(config.NewMemberRoleID)) { - await RespondAsync("You do not have the New Member or Member roles. Please contact staff to have this corrected.", ephemeral: true); + await RespondAsync(Strings.Command_CheckEligibility_Error_NoMemberRoles, ephemeral: true); return; } if (Context.User!.RoleIds.Contains(config.MemberRoleID)) { - await RespondAsync("You are already a member!", ephemeral: true); + await RespondAsync(Strings.Command_CheckEligibility_Error_AlreadyMember, ephemeral: true); return; } + + bool isDDBAMH = false; + try + { + var ddbUser = await ddbService.GetOrCreateUserAsync(Context.User); + isDDBAMH = ddbUser.Data.AutoMemberHoldRecord is not null; + } catch (Exception ex) + { + await metricService.Emit(Metric.AMS_DynamoFailures, 1); + Log.Error(ex, "Failed to retrieve AMH status for user {UserID} from DynamoDB", Context.User.Id); + } - if (Context.User!.RoleIds.Contains(config.AutoMemberConfig.HoldRole)) + if (Context.User!.RoleIds.Contains(config.AutoMemberConfig.HoldRole) || isDDBAMH) { // User is on hold - await RespondAsync(embed: BuildAMHEmbed(), ephemeral: true); + await RespondAsync(embed: new InstarCheckEligibilityAMHEmbed().Build(), ephemeral: true); return; } - var embed = await BuildEligibilityEmbed(config, Context.User); - Log.Debug("Responding..."); - await RespondAsync(embed: embed, ephemeral: true); + + var eligibility = autoMemberSystem.CheckEligibility(config, Context.User); + + await RespondAsync(embed: new InstarCheckEligibilityEmbed(Context.User, eligibility, config).Build(), ephemeral: true); await metricService.Emit(Metric.AMS_EligibilityCheck, 1); } @@ -70,29 +82,14 @@ public async Task CheckOtherEligibility(IUser user) var eligibility = autoMemberSystem.CheckEligibility(cfg, guildUser); - // Let's build a fancy embed - var fields = new List(); - - bool hasAMH = false; + AutoMemberHoldRecord? amhRecord = null; + bool hasError = false; try { var dbUser = await ddbService.GetOrCreateUserAsync(guildUser); if (dbUser.Data.AutoMemberHoldRecord is not null) { - StringBuilder amhContextBuilder = new(); - amhContextBuilder.AppendLine($"**Mod:** <@{dbUser.Data.AutoMemberHoldRecord.ModeratorID.ID}>"); - amhContextBuilder.AppendLine("**Reason:**"); - amhContextBuilder.AppendLine($"```{dbUser.Data.AutoMemberHoldRecord.Reason}```"); - - amhContextBuilder.Append("**Date:** "); - - var secondsSinceEpoch = (long) Math.Floor((dbUser.Data.AutoMemberHoldRecord.Date - DateTime.UnixEpoch).TotalSeconds); - amhContextBuilder.Append($" ()"); - - fields.Add(new EmbedFieldBuilder() - .WithName(":warning: Auto Member Hold") - .WithValue(amhContextBuilder.ToString())); - hasAMH = true; + amhRecord = dbUser.Data.AutoMemberHoldRecord; } } catch (Exception ex) { @@ -100,159 +97,9 @@ public async Task CheckOtherEligibility(IUser user) // Since we can't give exact details, we'll just note that there was an error // and just confirm that the member's AMH status is unknown. - fields.Add(new EmbedFieldBuilder() - .WithName(":warning: Possible Auto Member Hold") - .WithValue("Instar encountered an error while attempting to retrieve AMH details. If the user is otherwise eligible but is not being granted membership, it is possible that they are AMHed. Please try again later.")); - } - - // Only add eligibility requirements if the user is not AMHed - if (!hasAMH) - { - fields.Add(new EmbedFieldBuilder() - .WithName(":small_blue_diamond: Requirements") - .WithValue(BuildEligibilityText(eligibility))); + hasError = true; } - var builder = new EmbedBuilder() - .WithCurrentTimestamp() - .WithTitle("Membership Eligibility") - .WithFooter(new EmbedFooterBuilder() - .WithText("Instar Auto Member System") - .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) - .WithAuthor(new EmbedAuthorBuilder() - .WithName(user.Username) - .WithIconUrl(user.GetAvatarUrl())) - .WithDescription($"At this time, <@{user.Id}> is " + (!eligibility.HasFlag(MembershipEligibility.Eligible) ? "__not__ " : "") + " eligible for membership.") - .WithFields(fields); - - await RespondAsync(embed: builder.Build(), ephemeral: true); - } - - private static Embed BuildAMHEmbed() - { - var fields = new List - { - new EmbedFieldBuilder() - .WithName("Why?") - .WithValue("Your activity has been flagged as unusual by our system. This can happen for a variety of reasons, including antisocial behavior, repeated rule violations in a short period of time, or other behavior that is deemed disruptive."), - new EmbedFieldBuilder() - .WithName("What can I do?") - .WithValue("The staff will override an administrative hold in time when an evaluation of your behavior has been completed by the staff."), - new EmbedFieldBuilder() - .WithName("Should I contact Staff?") - .WithValue("No, the staff will not accelerate this process by request.") - }; - - var builder = new EmbedBuilder() - // Set up all the basic stuff first - .WithCurrentTimestamp() - .WithFooter(new EmbedFooterBuilder() - .WithText("Instar Auto Member System") - .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) - .WithTitle("Membership Eligibility") - .WithDescription("Your membership is currently on hold due to an administrative override. This means that you will not be granted the Member role automatically.") - .WithFields(fields); - - return builder.Build(); - } - - private async Task BuildEligibilityEmbed(InstarDynamicConfiguration config, IGuildUser user) - { - var eligibility = autoMemberSystem.CheckEligibility(config, user); - - Log.Debug("Building response embed..."); - var fields = new List(); - if (eligibility.HasFlag(MembershipEligibility.NotEligible)) - { - fields.Add(new EmbedFieldBuilder() - .WithName("Missing Items") - .WithValue(await BuildMissingItemsText(eligibility, user))); - } - - var nextRun = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, - DateTimeOffset.UtcNow.Day, DateTimeOffset.UtcNow.Hour, 0, 0, TimeSpan.Zero) - + TimeSpan.FromHours(1); - var unixTime = nextRun.UtcTicks / 10000000-62135596800; // UTC ticks since year 0 to Unix Timestamp - - fields.Add(new EmbedFieldBuilder() - .WithName("Note") - .WithValue($"The Auto Member System will run . Membership eligibility is subject to change at the time of evaluation.")); - - var builder = new EmbedBuilder() - // Set up all the basic stuff first - .WithCurrentTimestamp() - .WithColor(0x0c94e0) - .WithFooter(new EmbedFooterBuilder() - .WithText("Instar Auto Member System") - .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) - .WithTitle("Membership Eligibility") - .WithDescription(BuildEligibilityText(eligibility)) - .WithFields(fields); - - return builder.Build(); - } - - private async Task BuildMissingItemsText(MembershipEligibility eligibility, IGuildUser user) - { - var config = await dynamicConfig.GetConfig(); - - if (eligibility == MembershipEligibility.Eligible) - return string.Empty; - - var missingItemsBuilder = new StringBuilder(); - - if (eligibility.HasFlag(MembershipEligibility.MissingRoles)) - { - // What roles are we missing? - foreach (var roleGroup in config.AutoMemberConfig.RequiredRoles) - { - if (user.RoleIds.Intersect(roleGroup.Roles.Select(n => n.ID)).Any()) continue; - var prefix = "aeiouAEIOU".Contains(roleGroup.GroupName[0]) ? "an" : "a"; // grammar hack :) - missingItemsBuilder.AppendLine( - $"- You are missing {prefix} {roleGroup.GroupName.ToLowerInvariant()} role."); - } - } - - if (eligibility.HasFlag(MembershipEligibility.MissingIntroduction)) - missingItemsBuilder.AppendLine($"- You have not posted an introduction in {Snowflake.GetMention(() => config.AutoMemberConfig.IntroductionChannel)}."); - - if (eligibility.HasFlag(MembershipEligibility.TooYoung)) - missingItemsBuilder.AppendLine( - $"- You have not been on the server for {config.AutoMemberConfig.MinimumJoinAge / 3600} hours yet."); - - if (eligibility.HasFlag(MembershipEligibility.PunishmentReceived)) - missingItemsBuilder.AppendLine("- You have received a warning or moderator action."); - - if (eligibility.HasFlag(MembershipEligibility.NotEnoughMessages)) - missingItemsBuilder.AppendLine($"- You have not posted {config.AutoMemberConfig.MinimumMessages} messages in the past {config.AutoMemberConfig.MinimumMessageTime/3600} hours."); - - return missingItemsBuilder.ToString(); - } - - private static string BuildEligibilityText(MembershipEligibility eligibility) - { - var eligibilityBuilder = new StringBuilder(); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.MissingRoles) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Roles**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.MissingIntroduction) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Introduction**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.TooYoung) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Join Age**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.PunishmentReceived) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Mod Actions**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.NotEnoughMessages) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Messages** (last 24 hours)"); - - return eligibilityBuilder.ToString(); + await RespondAsync(embed: new InstarEligibilityEmbed(guildUser, eligibility, amhRecord, hasError).Build(), ephemeral: true); } } \ No newline at end of file diff --git a/InstarBot/Commands/PageCommand.cs b/InstarBot/Commands/PageCommand.cs index 453f338..ac885a0 100644 --- a/InstarBot/Commands/PageCommand.cs +++ b/InstarBot/Commands/PageCommand.cs @@ -1,13 +1,14 @@ -using System.Diagnostics.CodeAnalysis; -using Ardalis.GuardClauses; +using Ardalis.GuardClauses; using Discord; using Discord.Interactions; using JetBrains.Annotations; using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.Embeds; using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Preconditions; using PaxAndromeda.Instar.Services; using Serilog; +using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Commands; @@ -51,7 +52,7 @@ public async Task Page( string mention; if (team == PageTarget.Test) - mention = "This is a __**TEST**__ page."; + mention = Strings.Command_Page_TestPageMessage; else if (teamLead) mention = await teamService.GetTeamLeadMention(team); else @@ -60,7 +61,7 @@ public async Task Page( Log.Debug("Emitting page to {ChannelName}", Context.Channel?.Name); await RespondAsync( mention, - embed: BuildEmbed(reason, message, user, channel, userTeam!, Context.User), + embed: new InstarPageEmbed(reason, message, user, channel, userTeam!, Context.User).Build(), allowedMentions: AllowedMentions.All); await metricService.Emit(Metric.Paging_SentPages, 1); @@ -68,7 +69,7 @@ await RespondAsync( catch (Exception ex) { Log.Error(ex, "Failed to send page from {User}", Context.User.Id); - await RespondAsync("Failed to process command due to an internal server error.", ephemeral: true); + await RespondAsync(Strings.Command_Page_Error_Unexpected, ephemeral: true); } } @@ -88,7 +89,7 @@ private static bool CheckPermissions(IGuildUser user, Team? team, PageTarget pag if (team is null) { - response = "You are not authorized to use this command."; + response = Strings.Command_Page_Error_NotAuthorized; Log.Information("{User} was not authorized to send a page", user.Id); return false; } @@ -99,7 +100,7 @@ private static bool CheckPermissions(IGuildUser user, Team? team, PageTarget pag // Check permissions. Only mod+ can send an "all" page if (team.Priority > 3 && pageTarget == PageTarget.All) // i.e. Helper, Community Manager { - response = "You are not authorized to send a page to the entire staff team."; + response = Strings.Command_Page_Error_FullTeamNotAuthorized; Log.Information("{User} was not authorized to send a page to the entire staff team", user.Id); return false; } @@ -107,53 +108,8 @@ private static bool CheckPermissions(IGuildUser user, Team? team, PageTarget pag if (pageTarget != PageTarget.All || !teamLead) return true; - response = - "Failed to send page. The 'All' team does not have a teamleader. If intended to page the owner, please select the Owner as the team."; - return false; - } - - /// - /// Builds the page embed. - /// - /// The reason for the page. - /// A message link. May be null. - /// The user being paged about. May be null. - /// The channel being paged about. May be null. - /// The paging user's team - /// The paging user - /// A standard embed embodying all parameters provided - private static Embed BuildEmbed( - string reason, - string message, - IUser? targetUser, - IChannel? channel, - Team userTeam, - IGuildUser pagingUser) - { - var fields = new List(); - - if (targetUser is not null) - fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("User").WithValue($"<@{targetUser.Id}>")); - - if (channel is not null) - fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("Channel").WithValue($"<#{channel.Id}>")); - - if (!string.IsNullOrEmpty(message)) - fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("Message").WithValue(message)); - - var builder = new EmbedBuilder() - // Set up all the basic stuff first - .WithCurrentTimestamp() - .WithColor(userTeam.Color) - .WithAuthor(pagingUser.Nickname ?? pagingUser.Username, pagingUser.GetAvatarUrl()) - .WithFooter(new EmbedFooterBuilder() - .WithText("Instar Paging System") - .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) - // Description - .WithDescription($"```{reason}```") - .WithFields(fields); + response = Strings.Command_Page_Error_NoAllTeamlead; - var embed = builder.Build(); - return embed; + return false; } } \ No newline at end of file diff --git a/InstarBot/Commands/ReportUserCommand.cs b/InstarBot/Commands/ReportUserCommand.cs index 79fcefa..745f433 100644 --- a/InstarBot/Commands/ReportUserCommand.cs +++ b/InstarBot/Commands/ReportUserCommand.cs @@ -1,7 +1,9 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.Caching; +using Ardalis.GuardClauses; using Discord; using Discord.Interactions; +using PaxAndromeda.Instar.Embeds; using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Services; @@ -55,57 +57,21 @@ public async Task ModalResponse(ReportMessageModal modal) // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract if (message is null) { - await RespondAsync("Report expired. Please try again.", ephemeral: true); + await RespondAsync(Strings.Command_ReportUser_ReportExpired, ephemeral: true); return; } await SendReportMessage(modal, message, Context.Guild); - await RespondAsync("Your report has been sent.", ephemeral: true); + await RespondAsync(Strings.Command_ReportUser_ReportSent, ephemeral: true); } private async Task SendReportMessage(ReportMessageModal modal, IMessage message, IInstarGuild guild) { + Guard.Against.Null(Context.User); + var cfg = await dynamicConfig.GetConfig(); - var fields = new List - { - new EmbedFieldBuilder() - .WithIsInline(false) - .WithName("Message Content") - .WithValue($"```{message.Content}```"), - new EmbedFieldBuilder() - .WithIsInline(false) - .WithName("Reason") - .WithValue($"```{modal.ReportReason}```") - }; - - if (message.Author is not null) - fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("User") - .WithValue($"<@{message.Author.Id}>")); - - if (message.Channel is not null) - fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("Channel") - .WithValue($"<#{message.Channel.Id}>")); - - fields.Add(new EmbedFieldBuilder() - .WithIsInline(true) - .WithName("Message") - .WithValue($"https://discord.com/channels/{guild.Id}/{message.Channel?.Id}/{message.Id}")); - - fields.Add(new EmbedFieldBuilder().WithIsInline(false).WithName("Reported By") - .WithValue($"<@{Context.User!.Id}>")); - - var builder = new EmbedBuilder() - // Set up all the basic stuff first - .WithCurrentTimestamp() - .WithColor(0x0c94e0) - .WithAuthor(message.Author?.Username, message.Author?.GetAvatarUrl()) - .WithFooter(new EmbedFooterBuilder() - .WithText("Instar Message Reporting System") - .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) - .WithFields(fields); - #if DEBUG const string staffPing = "{{staffping}}"; #else @@ -114,7 +80,7 @@ private async Task SendReportMessage(ReportMessageModal modal, IMessage message, await Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel) - .SendMessageAsync(staffPing, embed: builder.Build()); + .SendMessageAsync(staffPing, embed: new InstarReportUserEmbed(modal, Context.User, message, guild).Build()); await metricService.Emit(Metric.ReportUser_ReportsSent, 1); } diff --git a/InstarBot/Embeds/InstarAutoMemberSystemEmbed.cs b/InstarBot/Embeds/InstarAutoMemberSystemEmbed.cs new file mode 100644 index 0000000..ce232de --- /dev/null +++ b/InstarBot/Embeds/InstarAutoMemberSystemEmbed.cs @@ -0,0 +1,28 @@ +using Discord; + +namespace PaxAndromeda.Instar.Embeds; + +public abstract class InstarAutoMemberSystemEmbed (string title) : InstarEmbed +{ + public string Title { get; } = title; + + // There are a couple of variants of this embed, such as the CheckEligibility embed, + // staff eligibility check embed, and the auto-member hold notification embed. + + public override Embed Build() + { + var builder = new EmbedBuilder() + .WithCurrentTimestamp() + .WithTitle(Title) + .WithFooter(new EmbedFooterBuilder() + .WithText(Strings.Embed_AMS_Footer) + .WithIconUrl(InstarLogoUrl)); + + // Let subclasses build out the rest of the embed. + builder = BuildParts(builder); + + return builder.Build(); + } + + protected abstract EmbedBuilder BuildParts(EmbedBuilder builder); +} \ No newline at end of file diff --git a/InstarBot/Embeds/InstarCheckEligibilityAMHEmbed.cs b/InstarBot/Embeds/InstarCheckEligibilityAMHEmbed.cs new file mode 100644 index 0000000..04c5929 --- /dev/null +++ b/InstarBot/Embeds/InstarCheckEligibilityAMHEmbed.cs @@ -0,0 +1,26 @@ +using Discord; + +namespace PaxAndromeda.Instar.Embeds; + +public class InstarCheckEligibilityAMHEmbed() : InstarAutoMemberSystemEmbed(Strings.Command_CheckEligibility_EmbedTitle) +{ + protected override EmbedBuilder BuildParts(EmbedBuilder builder) + { + var fields = new List + { + new EmbedFieldBuilder() + .WithName("Why?") + .WithValue(Strings.Command_CheckEligibility_AMH_Why), + new EmbedFieldBuilder() + .WithName("What can I do?") + .WithValue(Strings.Command_CheckEligibility_AMH_WhatToDo), + new EmbedFieldBuilder() + .WithName("Should I contact Staff?") + .WithValue(Strings.Command_CheckEligibility_AMH_ContactStaff) + }; + + return builder + .WithDescription(Strings.Command_CheckEligibility_AMH_MembershipWithheld) + .WithFields(fields); + } +} \ No newline at end of file diff --git a/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs b/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs new file mode 100644 index 0000000..e584198 --- /dev/null +++ b/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs @@ -0,0 +1,99 @@ +using Discord; +using System.Text; +using PaxAndromeda.Instar.ConfigModels; + +namespace PaxAndromeda.Instar.Embeds; + +public class InstarCheckEligibilityEmbed(IGuildUser user, MembershipEligibility eligibility, InstarDynamicConfiguration config) + : InstarAutoMemberSystemEmbed(Strings.Command_CheckEligibility_EmbedTitle) +{ + protected override EmbedBuilder BuildParts(EmbedBuilder builder) + { + // We only have to focus on the description, author and fields here + + var fields = new List(); + if (eligibility.HasFlag(MembershipEligibility.NotEligible)) + { + fields.Add(new EmbedFieldBuilder() + .WithName("Missing Items") + .WithValue(BuildMissingItemsText())); + } + + var nextRun = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, + DateTimeOffset.UtcNow.Day, DateTimeOffset.UtcNow.Hour, 0, 0, TimeSpan.Zero) + + TimeSpan.FromHours(1); + var unixTime = nextRun.UtcTicks / 10000000-62135596800; // UTC ticks since year 0 to Unix Timestamp + + fields.Add(new EmbedFieldBuilder() + .WithName("Note") + .WithValue(string.Format(Strings.Command_CheckEligibility_NextRuntimeNote, unixTime))); + + return builder + .WithAuthor(new EmbedAuthorBuilder() + .WithName(user.Username) + .WithIconUrl(user.GetAvatarUrl())) + .WithDescription(BuildEligibilityText()) + .WithFields(fields); + } + + private string BuildEligibilityText() + { + var eligibilityBuilder = new StringBuilder(); + + + eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_RolesEligibility, !eligibility.HasFlag(MembershipEligibility.MissingRoles))); + eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_IntroductionEligibility, !eligibility.HasFlag(MembershipEligibility.MissingIntroduction))); + eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_JoinAgeEligibility, !eligibility.HasFlag(MembershipEligibility.TooYoung))); + eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_ModActionsEligibility, !eligibility.HasFlag(MembershipEligibility.PunishmentReceived))); + eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_MessagesEligibility, !eligibility.HasFlag(MembershipEligibility.NotEnoughMessages))); + + return eligibilityBuilder.ToString(); + } + + private static string BuildEligibilityComponent(string format, bool eligible) + { + return string.Format( + format, + eligible + ? Strings.Command_CheckEligibility_EligibleEmoji + : Strings.Command_CheckEligibility_NotEligibleEmoji + ); + } + + private string BuildMissingItemsText() + { + if (eligibility == MembershipEligibility.Eligible) + return string.Empty; + + + var missingItems = new List(); + + if (eligibility.HasFlag(MembershipEligibility.MissingRoles)) + { + // What roles are we missing? + missingItems.AddRange( + from roleGroup in config.AutoMemberConfig.RequiredRoles + where !user.RoleIds.Intersect(roleGroup.Roles.Select(n => n.ID)).Any() + let article = "aeiouAEIOU".Contains(roleGroup.GroupName[0]) ? "an" : "a" // grammar hack + select string.Format(Strings.Command_CheckEligibility_MissingItem_Role, article, roleGroup.GroupName.ToLowerInvariant())); + } + + if (eligibility.HasFlag(MembershipEligibility.MissingIntroduction)) + missingItems.Add(string.Format(Strings.Command_CheckEligibility_MissingItem_Introduction, Snowflake.GetMention(() => config.AutoMemberConfig.IntroductionChannel))); + + if (eligibility.HasFlag(MembershipEligibility.TooYoung)) + missingItems.Add(string.Format(Strings.Command_CheckEligibility_MissingItem_TooYoung, config.AutoMemberConfig.MinimumJoinAge / 3600)); + + if (eligibility.HasFlag(MembershipEligibility.PunishmentReceived)) + missingItems.Add(Strings.Command_CheckEligibility_MissingItem_PunishmentReceived); + + if (eligibility.HasFlag(MembershipEligibility.NotEnoughMessages)) + missingItems.Add(string.Format(Strings.Command_CheckEligibility_MissingItem_Messages, config.AutoMemberConfig.MinimumMessages, config.AutoMemberConfig.MinimumMessageTime / 3600)); + + var missingItemsBuilder = new StringBuilder(); + foreach (var item in missingItems) + missingItemsBuilder.AppendLine($"- {item}"); + + return missingItemsBuilder.ToString(); + } +} \ No newline at end of file diff --git a/InstarBot/Embeds/InstarEligibilityEmbed.cs b/InstarBot/Embeds/InstarEligibilityEmbed.cs new file mode 100644 index 0000000..f5dce9c --- /dev/null +++ b/InstarBot/Embeds/InstarEligibilityEmbed.cs @@ -0,0 +1,71 @@ +using Discord; +using PaxAndromeda.Instar.DynamoModels; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace PaxAndromeda.Instar.Embeds; + +public class InstarEligibilityEmbed(IGuildUser user, MembershipEligibility eligibility, AutoMemberHoldRecord? amhRecord, bool error = false) + : InstarAutoMemberSystemEmbed(Strings.Command_CheckEligibility_EmbedTitle) +{ + protected override EmbedBuilder BuildParts(EmbedBuilder builder) + { + var fields = new List(); + + bool hasAMH = false; + if (amhRecord is not null) + { + var secondsSinceEpoch = (long) Math.Floor((amhRecord.Date - DateTime.UnixEpoch).TotalSeconds); + fields.Add(new EmbedFieldBuilder() + .WithName(Strings.Command_Eligibility_Section_Hold) + .WithValue(string.Format(Strings.Command_Eligibility_HoldFormat, amhRecord.ModeratorID.ID, amhRecord.Reason, secondsSinceEpoch))); + hasAMH = true; + } + + if (!hasAMH && error) + { + fields.Add(new EmbedFieldBuilder() + .WithName(Strings.Command_Eligibility_Section_AmbiguousHold) + .WithValue(Strings.Command_Eligibility_Error_AmbiguousHold)); + } + + // Only add eligibility requirements if the user is not AMHed + if (!hasAMH) + { + fields.Add(new EmbedFieldBuilder() + .WithName(Strings.Command_Eligibility_Section_Requirements) + .WithValue(BuildEligibilityText(eligibility))); + } + + return builder + .WithAuthor(new EmbedAuthorBuilder() + .WithName(user.Username) + .WithIconUrl(user.GetAvatarUrl()) + ) + .WithDescription(eligibility.HasFlag(MembershipEligibility.Eligible) ? Strings.Command_Eligibility_EligibleText : Strings.Command_Eligibility_IneligibleText) + .WithFields(fields); + } + + + [ExcludeFromCodeCoverage(Justification = "This method's output is actually tested by observing the embed output.")] + private static string BuildEligibilityText(MembershipEligibility eligibility) + { + var eligibilityBuilder = new StringBuilder(); + + eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_RolesEligibility, !eligibility.HasFlag(MembershipEligibility.MissingRoles))); + eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_IntroductionEligibility, !eligibility.HasFlag(MembershipEligibility.MissingIntroduction))); + eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_JoinAgeEligibility, !eligibility.HasFlag(MembershipEligibility.TooYoung))); + eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_ModActionsEligibility, !eligibility.HasFlag(MembershipEligibility.PunishmentReceived))); + eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_MessagesEligibility, !eligibility.HasFlag(MembershipEligibility.NotEnoughMessages))); + + return eligibilityBuilder.ToString(); + } + + [ExcludeFromCodeCoverage(Justification = "This method's output is actually tested by observing the embed output.")] + private static string BuildEligibilitySnippet(string format, bool isEligible) + { + return string.Format(format, isEligible + ? Strings.Command_CheckEligibility_EligibleEmoji + : Strings.Command_CheckEligibility_NotEligibleEmoji); + } +} \ No newline at end of file diff --git a/InstarBot/Embeds/InstarEmbed.cs b/InstarBot/Embeds/InstarEmbed.cs new file mode 100644 index 0000000..a795ffa --- /dev/null +++ b/InstarBot/Embeds/InstarEmbed.cs @@ -0,0 +1,10 @@ +using Discord; + +namespace PaxAndromeda.Instar.Embeds; + +public abstract class InstarEmbed +{ + public const string InstarLogoUrl = "https://spacegirl.s3.us-east-1.amazonaws.com/instar.png"; + + public abstract Embed Build(); +} \ No newline at end of file diff --git a/InstarBot/Embeds/InstarPageEmbed.cs b/InstarBot/Embeds/InstarPageEmbed.cs new file mode 100644 index 0000000..8f408cb --- /dev/null +++ b/InstarBot/Embeds/InstarPageEmbed.cs @@ -0,0 +1,44 @@ +using Discord; +using PaxAndromeda.Instar.ConfigModels; +using System.Threading.Channels; + +namespace PaxAndromeda.Instar.Embeds; + +public class InstarPageEmbed ( + string reason, + string message, + IUser? targetUser, + IChannel? targetChannel, + Team pagingTeam, + IGuildUser pagingUser) + : InstarEmbed +{ + public override Embed Build() + { + var fields = new List(); + + if (targetUser is not null) + fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("User").WithValue($"<@{targetUser.Id}>")); + + if (targetChannel is not null) + fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("Channel").WithValue($"<#{targetChannel.Id}>")); + + if (!string.IsNullOrEmpty(message)) + fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("Message").WithValue(message)); + + var builder = new EmbedBuilder() + // Set up all the basic stuff first + .WithCurrentTimestamp() + .WithColor(pagingTeam.Color) + .WithAuthor(pagingUser.Nickname ?? pagingUser.Username, pagingUser.GetAvatarUrl()) + .WithFooter(new EmbedFooterBuilder() + .WithText(Strings.Embed_Page_Footer) + .WithIconUrl(Strings.InstarLogoUrl)) + // Description + .WithDescription($"```{reason}```") + .WithFields(fields); + + var embed = builder.Build(); + return embed; + } +} \ No newline at end of file diff --git a/InstarBot/Embeds/InstarReportUserEmbed.cs b/InstarBot/Embeds/InstarReportUserEmbed.cs new file mode 100644 index 0000000..673f4b3 --- /dev/null +++ b/InstarBot/Embeds/InstarReportUserEmbed.cs @@ -0,0 +1,51 @@ +using Discord; +using PaxAndromeda.Instar.Modals; +using System; + +namespace PaxAndromeda.Instar.Embeds; + +public class InstarReportUserEmbed(ReportMessageModal modal, IGuildUser contextUser, IMessage message, IInstarGuild guild) : InstarEmbed +{ + public override Embed Build() + { + var fields = new List + { + new EmbedFieldBuilder() + .WithIsInline(false) + .WithName("Message Content") + .WithValue($"```{message.Content}```"), + new EmbedFieldBuilder() + .WithIsInline(false) + .WithName("Reason") + .WithValue($"```{modal.ReportReason}```") + }; + + if (message.Author is not null) + fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("User") + .WithValue($"<@{message.Author.Id}>")); + + if (message.Channel is not null) + fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("Channel") + .WithValue($"<#{message.Channel.Id}>")); + + fields.Add(new EmbedFieldBuilder() + .WithIsInline(true) + .WithName("Message") + .WithValue($"https://discord.com/channels/{guild.Id}/{message.Channel?.Id}/{message.Id}")); + + fields.Add(new EmbedFieldBuilder().WithIsInline(false).WithName("Reported By") + .WithValue($"<@{contextUser.Id}>")); + + var builder = new EmbedBuilder() + // Set up all the basic stuff first + .WithCurrentTimestamp() + .WithColor(0x0c94e0) + .WithAuthor(message.Author?.Username, message.Author?.GetAvatarUrl()) + .WithFooter(new EmbedFooterBuilder() + .WithText(Strings.Embed_UserReport_Footer) + .WithIconUrl(Strings.InstarLogoUrl)) + .WithFields(fields); + + return builder.Build(); + } +} \ No newline at end of file diff --git a/InstarBot/InstarBot.csproj b/InstarBot/InstarBot.csproj index 8b801ab..d7365e4 100644 --- a/InstarBot/InstarBot.csproj +++ b/InstarBot/InstarBot.csproj @@ -41,6 +41,21 @@ + + + True + True + Strings.resx + + + + + + PublicResXFileCodeGenerator + Strings.Designer.cs + + + PreserveNewest diff --git a/InstarBot/InstarBot.csproj.DotSettings b/InstarBot/InstarBot.csproj.DotSettings new file mode 100644 index 0000000..6e7fff8 --- /dev/null +++ b/InstarBot/InstarBot.csproj.DotSettings @@ -0,0 +1,2 @@ + + No \ No newline at end of file diff --git a/InstarBot/Metrics/MetricDimensionAttribute.cs b/InstarBot/Metrics/MetricDimensionAttribute.cs index 7de31d8..a126a9e 100644 --- a/InstarBot/Metrics/MetricDimensionAttribute.cs +++ b/InstarBot/Metrics/MetricDimensionAttribute.cs @@ -1,5 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + namespace PaxAndromeda.Instar.Metrics; +[ExcludeFromCodeCoverage] [AttributeUsage(AttributeTargets.Field)] public sealed class MetricDimensionAttribute(string name, string value) : Attribute { diff --git a/InstarBot/Metrics/MetricNameAttribute.cs b/InstarBot/Metrics/MetricNameAttribute.cs index 4a4414b..2247ae9 100644 --- a/InstarBot/Metrics/MetricNameAttribute.cs +++ b/InstarBot/Metrics/MetricNameAttribute.cs @@ -1,5 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + namespace PaxAndromeda.Instar.Metrics; +[ExcludeFromCodeCoverage] [AttributeUsage(AttributeTargets.Field)] public sealed class MetricNameAttribute(string name) : Attribute { diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index 79e717e..c35575c 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -401,6 +401,8 @@ public MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IG if (eligibility != MembershipEligibility.Eligible) { + // Unset Eligible flag, add NotEligible flag. + // OPTIMIZE: Do we need the NotEligible flag at all? eligibility &= ~MembershipEligibility.Eligible; eligibility |= MembershipEligibility.NotEligible; } diff --git a/InstarBot/Strings.Designer.cs b/InstarBot/Strings.Designer.cs new file mode 100644 index 0000000..660fe50 --- /dev/null +++ b/InstarBot/Strings.Designer.cs @@ -0,0 +1,495 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace PaxAndromeda.Instar { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Strings { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Strings() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("PaxAndromeda.Instar.Strings", typeof(Strings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Error while attempting to withhold membership for <@{0}>: User is already a member.. + /// + public static string Command_AutoMemberHold_Error_AlreadyMember { + get { + return ResourceManager.GetString("Command_AutoMemberHold_Error_AlreadyMember", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error while attempting to withhold membership for <@{0}>: User already has an effective membership hold.. + /// + public static string Command_AutoMemberHold_Error_AMHAlreadyExists { + get { + return ResourceManager.GetString("Command_AutoMemberHold_Error_AMHAlreadyExists", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error while attempting to withhold membership for <@{0}>: User is not a guild member.. + /// + public static string Command_AutoMemberHold_Error_NotGuildMember { + get { + return ResourceManager.GetString("Command_AutoMemberHold_Error_NotGuildMember", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error while attempting to withhold membership for <@{0}>: An unexpected error occurred while configuring the AMH.. + /// + public static string Command_AutoMemberHold_Error_Unexpected { + get { + return ResourceManager.GetString("Command_AutoMemberHold_Error_Unexpected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Membership for user <@{0}> has been withheld. Staff will be notified in one week to review.. + /// + public static string Command_AutoMemberHold_Success { + get { + return ResourceManager.GetString("Command_AutoMemberHold_Success", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error while attempting to remove auto member hold for <@{0}>: User does not have an active auto member hold.. + /// + public static string Command_AutoMemberUnhold_Error_NoActiveHold { + get { + return ResourceManager.GetString("Command_AutoMemberUnhold_Error_NoActiveHold", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error while attempting to remove auto member hold for <@{0}>: User is not a guild member.. + /// + public static string Command_AutoMemberUnhold_Error_NotGuildMember { + get { + return ResourceManager.GetString("Command_AutoMemberUnhold_Error_NotGuildMember", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error while attempting to remove auto member hold for <@{0}>: An unexpected error has occurred while removing the AMH.. + /// + public static string Command_AutoMemberUnhold_Error_Unexpected { + get { + return ResourceManager.GetString("Command_AutoMemberUnhold_Error_Unexpected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Auto member hold for user <@{0}> has been removed.. + /// + public static string Command_AutoMemberUnhold_Success { + get { + return ResourceManager.GetString("Command_AutoMemberUnhold_Success", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No, the staff will not accelerate this process by request.. + /// + public static string Command_CheckEligibility_AMH_ContactStaff { + get { + return ResourceManager.GetString("Command_CheckEligibility_AMH_ContactStaff", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your membership is currently on hold due to an administrative override. This means that you will not be granted the Member role automatically.. + /// + public static string Command_CheckEligibility_AMH_MembershipWithheld { + get { + return ResourceManager.GetString("Command_CheckEligibility_AMH_MembershipWithheld", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The staff will override an administrative hold in time when an evaluation of your behavior has been completed by the staff.. + /// + public static string Command_CheckEligibility_AMH_WhatToDo { + get { + return ResourceManager.GetString("Command_CheckEligibility_AMH_WhatToDo", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your activity has been flagged as unusual by our system. This can happen for a variety of reasons, including antisocial behavior, repeated rule violations in a short period of time, or other behavior that is deemed disruptive.. + /// + public static string Command_CheckEligibility_AMH_Why { + get { + return ResourceManager.GetString("Command_CheckEligibility_AMH_Why", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to :white_check_mark:. + /// + public static string Command_CheckEligibility_EligibleEmoji { + get { + return ResourceManager.GetString("Command_CheckEligibility_EligibleEmoji", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Membership Eligibility. + /// + public static string Command_CheckEligibility_EmbedTitle { + get { + return ResourceManager.GetString("Command_CheckEligibility_EmbedTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are already a member!. + /// + public static string Command_CheckEligibility_Error_AlreadyMember { + get { + return ResourceManager.GetString("Command_CheckEligibility_Error_AlreadyMember", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An internal error has occurred. Please try again later.. + /// + public static string Command_CheckEligibility_Error_Internal { + get { + return ResourceManager.GetString("Command_CheckEligibility_Error_Internal", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You do not have the New Member or Member roles. Please contact staff to have this corrected.. + /// + public static string Command_CheckEligibility_Error_NoMemberRoles { + get { + return ResourceManager.GetString("Command_CheckEligibility_Error_NoMemberRoles", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} **Introduction**. + /// + public static string Command_CheckEligibility_IntroductionEligibility { + get { + return ResourceManager.GetString("Command_CheckEligibility_IntroductionEligibility", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} **Join Age**. + /// + public static string Command_CheckEligibility_JoinAgeEligibility { + get { + return ResourceManager.GetString("Command_CheckEligibility_JoinAgeEligibility", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} **Messages** (last 24 hours). + /// + public static string Command_CheckEligibility_MessagesEligibility { + get { + return ResourceManager.GetString("Command_CheckEligibility_MessagesEligibility", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You have not posted an introduction in {0}.. + /// + public static string Command_CheckEligibility_MissingItem_Introduction { + get { + return ResourceManager.GetString("Command_CheckEligibility_MissingItem_Introduction", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You have not posted {0} messages in the past {1} hours.. + /// + public static string Command_CheckEligibility_MissingItem_Messages { + get { + return ResourceManager.GetString("Command_CheckEligibility_MissingItem_Messages", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You have received a warning or moderator action.. + /// + public static string Command_CheckEligibility_MissingItem_PunishmentReceived { + get { + return ResourceManager.GetString("Command_CheckEligibility_MissingItem_PunishmentReceived", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are missing {0} {1} role.. + /// + public static string Command_CheckEligibility_MissingItem_Role { + get { + return ResourceManager.GetString("Command_CheckEligibility_MissingItem_Role", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You have not been on the server for {0} hours yet.. + /// + public static string Command_CheckEligibility_MissingItem_TooYoung { + get { + return ResourceManager.GetString("Command_CheckEligibility_MissingItem_TooYoung", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} **Mod Actions**. + /// + public static string Command_CheckEligibility_ModActionsEligibility { + get { + return ResourceManager.GetString("Command_CheckEligibility_ModActionsEligibility", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Auto Member System will run <t:{0}:R>. Membership eligibility is subject to change at the time of evaluation.. + /// + public static string Command_CheckEligibility_NextRuntimeNote { + get { + return ResourceManager.GetString("Command_CheckEligibility_NextRuntimeNote", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to :x:. + /// + public static string Command_CheckEligibility_NotEligibleEmoji { + get { + return ResourceManager.GetString("Command_CheckEligibility_NotEligibleEmoji", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} **Roles**. + /// + public static string Command_CheckEligibility_RolesEligibility { + get { + return ResourceManager.GetString("Command_CheckEligibility_RolesEligibility", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to At this time, <@{0}> is eligible for membership.. + /// + public static string Command_Eligibility_EligibleText { + get { + return ResourceManager.GetString("Command_Eligibility_EligibleText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Instar encountered an error while attempting to retrieve AMH details. If the user is otherwise eligible but is not being granted membership, it is possible that they are AMHed. Please try again later.. + /// + public static string Command_Eligibility_Error_AmbiguousHold { + get { + return ResourceManager.GetString("Command_Eligibility_Error_AmbiguousHold", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to **Mod:** <@{0}>\r\n**Reason:**\r\n```{1}```\r\n**Date:** <t:{2}:f> (<t:{2}:R>). + /// + public static string Command_Eligibility_HoldFormat { + get { + return ResourceManager.GetString("Command_Eligibility_HoldFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to At this time, <@{0}> is __not__ eligible for membership.. + /// + public static string Command_Eligibility_IneligibleText { + get { + return ResourceManager.GetString("Command_Eligibility_IneligibleText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to :warning: Possible Auto Member Hold. + /// + public static string Command_Eligibility_Section_AmbiguousHold { + get { + return ResourceManager.GetString("Command_Eligibility_Section_AmbiguousHold", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to :warning: Auto Member Hold. + /// + public static string Command_Eligibility_Section_Hold { + get { + return ResourceManager.GetString("Command_Eligibility_Section_Hold", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Requirements. + /// + public static string Command_Eligibility_Section_Requirements { + get { + return ResourceManager.GetString("Command_Eligibility_Section_Requirements", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are not authorized to send a page to the entire staff team.. + /// + public static string Command_Page_Error_FullTeamNotAuthorized { + get { + return ResourceManager.GetString("Command_Page_Error_FullTeamNotAuthorized", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to send page. The 'All' team does not have a teamleader. If intended to page the owner, please select the Owner as the team.. + /// + public static string Command_Page_Error_NoAllTeamlead { + get { + return ResourceManager.GetString("Command_Page_Error_NoAllTeamlead", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are not authorized to use this command.. + /// + public static string Command_Page_Error_NotAuthorized { + get { + return ResourceManager.GetString("Command_Page_Error_NotAuthorized", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to process command due to an internal server error.. + /// + public static string Command_Page_Error_Unexpected { + get { + return ResourceManager.GetString("Command_Page_Error_Unexpected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This is a __**TEST**__ page.. + /// + public static string Command_Page_TestPageMessage { + get { + return ResourceManager.GetString("Command_Page_TestPageMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Report expired. Please try again.. + /// + public static string Command_ReportUser_ReportExpired { + get { + return ResourceManager.GetString("Command_ReportUser_ReportExpired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your report has been sent.. + /// + public static string Command_ReportUser_ReportSent { + get { + return ResourceManager.GetString("Command_ReportUser_ReportSent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Instar Auto Member System. + /// + public static string Embed_AMS_Footer { + get { + return ResourceManager.GetString("Embed_AMS_Footer", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Instar Paging System. + /// + public static string Embed_Page_Footer { + get { + return ResourceManager.GetString("Embed_Page_Footer", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Instar Message Reporting System. + /// + public static string Embed_UserReport_Footer { + get { + return ResourceManager.GetString("Embed_UserReport_Footer", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to https://spacegirl.s3.us-east-1.amazonaws.com/instar.png. + /// + public static string InstarLogoUrl { + get { + return ResourceManager.GetString("InstarLogoUrl", resourceCulture); + } + } + } +} diff --git a/InstarBot/Strings.resx b/InstarBot/Strings.resx new file mode 100644 index 0000000..70110a6 --- /dev/null +++ b/InstarBot/Strings.resx @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + No, the staff will not accelerate this process by request. + + + Your membership is currently on hold due to an administrative override. This means that you will not be granted the Member role automatically. + + + The staff will override an administrative hold in time when an evaluation of your behavior has been completed by the staff. + + + Your activity has been flagged as unusual by our system. This can happen for a variety of reasons, including antisocial behavior, repeated rule violations in a short period of time, or other behavior that is deemed disruptive. + + + :white_check_mark: + + + Membership Eligibility + Title of the embed returned when calling /checkeligibility + + + An internal error has occurred. Please try again later. + + + {0} **Introduction** + {0} is an emoji, linked to Command_CheckEligibility_EligibleEmoji and Command_CheckEligibility_NotEligibleEmoji + + + {0} **Join Age** + {0} is an emoji, linked to Command_CheckEligibility_EligibleEmoji and Command_CheckEligibility_NotEligibleEmoji + + + {0} **Messages** (last 24 hours) + {0} is an emoji, linked to Command_CheckEligibility_EligibleEmoji and Command_CheckEligibility_NotEligibleEmoji + + + You have not posted an introduction in {0}. + {0} is a link to the Introductions channel. + + + You have not posted {0} messages in the past {1} hours. + {0} is the number of messages, and {1} is the timeframe in hours they need to send them in. + + + You have received a warning or moderator action. + + + You are missing {0} {1} role. + {0} is an article matched to the value of {1}. For example, "an age role" or "a gender identity role". + + + You have not been on the server for {0} hours yet. + {0} is the number of hours pulled from config. + + + {0} **Mod Actions** + {0} is an emoji, linked to Command_CheckEligibility_EligibleEmoji and Command_CheckEligibility_NotEligibleEmoji + + + The Auto Member System will run <t:{0}:R>. Membership eligibility is subject to change at the time of evaluation. + {0} is a unix timestamp measured in seconds. + + + You do not have the New Member or Member roles. Please contact staff to have this corrected. + + + :x: + + + {0} **Roles** + {0} is an emoji, linked to Command_CheckEligibility_EligibleEmoji and Command_CheckEligibility_NotEligibleEmoji + + + Instar Auto Member System + + + You are already a member! + + + At this time, <@{0}> is eligible for membership. + {0} is the user's ID. + + + At this time, <@{0}> is __not__ eligible for membership. + {0} is the user's ID. + + + Instar encountered an error while attempting to retrieve AMH details. If the user is otherwise eligible but is not being granted membership, it is possible that they are AMHed. Please try again later. + + + :warning: Possible Auto Member Hold + + + :warning: Auto Member Hold + + + Requirements + + + Error while attempting to withhold membership for <@{0}>: User is not a guild member. + {0} is the target user ID. + + + Error while attempting to withhold membership for <@{0}>: User is already a member. + {0} is the target user ID. + + + **Mod:** <@{0}>\r\n**Reason:**\r\n```{1}```\r\n**Date:** <t:{2}:f> (<t:{2}:R>) + {0} is the moderator ID, {1} is the reason, and {2} is the unix timestamp of the issue date in seconds + + + Error while attempting to withhold membership for <@{0}>: User already has an effective membership hold. + {0} is the target user ID. + + + Error while attempting to withhold membership for <@{0}>: An unexpected error occurred while configuring the AMH. + {0} is the target user ID. + + + Membership for user <@{0}> has been withheld. Staff will be notified in one week to review. + {0} is the target user ID. + + + Error while attempting to remove auto member hold for <@{0}>: User is not a guild member. + {0} is the target user ID. + + + Error while attempting to remove auto member hold for <@{0}>: User does not have an active auto member hold. + {0} is the target user ID. + + + Error while attempting to remove auto member hold for <@{0}>: An unexpected error has occurred while removing the AMH. + {0} is the target user ID. + + + Auto member hold for user <@{0}> has been removed. + {0} is the target user ID. + + + https://spacegirl.s3.us-east-1.amazonaws.com/instar.png + + + Instar Paging System + + + You are not authorized to use this command. + + + You are not authorized to send a page to the entire staff team. + + + Failed to send page. The 'All' team does not have a teamleader. If intended to page the owner, please select the Owner as the team. + + + Failed to process command due to an internal server error. + + + This is a __**TEST**__ page. + + + Your report has been sent. + + + Report expired. Please try again. + + + Instar Message Reporting System + + \ No newline at end of file From dc622b974e01a761018c70d7fd809232f4dca066 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Fri, 12 Dec 2025 15:40:40 -0800 Subject: [PATCH 09/53] Added birthday system implementation. --- .../Instar.dynamic.test.debug.conf.json | 44 +++ InstarBot.Tests.Common/Models/TestChannel.cs | 7 +- InstarBot.Tests.Common/Models/TestGuild.cs | 14 +- .../Models/TestGuildUser.cs | 13 +- InstarBot.Tests.Common/Models/TestMessage.cs | 75 ++++- .../Services/MockAutoMemberSystem.cs | 5 + .../Services/MockDiscordService.cs | 40 ++- .../Services/MockInstarDDBService.cs | 19 +- InstarBot.Tests.Common/TestContext.cs | 11 +- InstarBot.Tests.Common/TestUtilities.cs | 62 +++- .../AutoMemberSystemCommandTests.cs | 4 +- .../CheckEligibilityCommandTests.cs | 17 +- .../Interactions/PageCommandTests.cs | 1 - .../Interactions/ResetBirthdayCommandTests.cs | 133 ++++++++ .../Interactions/SetBirthdayCommandTests.cs | 228 +++++++++++-- .../AutoMemberSystemTests.cs | 125 ++++--- .../Services/BirthdaySystemTests.cs | 306 ++++++++++++++++++ .../AsyncAutoResetEventTests.cs | 74 +++++ InstarBot.Tests.Unit/BirthdayTests.cs | 94 ++++++ InstarBot/AsyncAutoResetEvent.cs | 82 +++++ InstarBot/AsyncEvent.cs | 4 +- InstarBot/Birthday.cs | 118 +++++++ InstarBot/Commands/AutoMemberHoldCommand.cs | 4 +- InstarBot/Commands/ResetBirthdayCommand.cs | 74 +++++ InstarBot/Commands/SetBirthdayCommand.cs | 191 +++++++---- .../Commands/TriggerBirthdaySystemCommand.cs | 21 ++ .../InstarDynamicConfiguration.cs | 24 +- .../ArbitraryDynamoDBTypeConverter.cs | 95 +++--- InstarBot/DynamoModels/InstarUserData.cs | 54 +++- .../Embeds/InstarCheckEligibilityEmbed.cs | 6 +- InstarBot/Embeds/InstarEligibilityEmbed.cs | 2 +- InstarBot/Embeds/InstarPageEmbed.cs | 1 - InstarBot/Embeds/InstarReportUserEmbed.cs | 1 - .../Embeds/InstarUnderageUserWarningEmbed.cs | 28 ++ InstarBot/MembershipEligibility.cs | 53 ++- InstarBot/Metrics/Metric.cs | 20 +- InstarBot/Program.cs | 38 ++- InstarBot/Services/AWSDynamicConfigService.cs | 12 +- InstarBot/Services/AutoMemberSystem.cs | 118 +++++-- InstarBot/Services/BirthdaySystem.cs | 276 ++++++++++++++++ InstarBot/Services/CloudwatchMetricService.cs | 110 ++++--- InstarBot/Services/DiscordService.cs | 68 +++- InstarBot/Services/FileSystemMetricService.cs | 28 ++ InstarBot/Services/GaiusAPIService.cs | 12 +- InstarBot/Services/IAutoMemberSystem.cs | 2 + InstarBot/Services/IBirthdaySystem.cs | 18 ++ InstarBot/Services/IDiscordService.cs | 15 +- InstarBot/Services/IInstarDDBService.cs | 14 + InstarBot/Services/InstarDDBService.cs | 76 ++++- InstarBot/Snowflake.cs | 3 +- InstarBot/Strings.Designer.cs | 164 ++++++++++ InstarBot/Strings.resx | 67 ++++ InstarBot/Utilities.cs | 246 +++++++------- 53 files changed, 2807 insertions(+), 510 deletions(-) create mode 100644 InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs rename InstarBot.Tests.Integration/{Interactions => Services}/AutoMemberSystemTests.cs (84%) create mode 100644 InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs create mode 100644 InstarBot.Tests.Unit/AsyncAutoResetEventTests.cs create mode 100644 InstarBot.Tests.Unit/BirthdayTests.cs create mode 100644 InstarBot/AsyncAutoResetEvent.cs create mode 100644 InstarBot/Birthday.cs create mode 100644 InstarBot/Commands/ResetBirthdayCommand.cs create mode 100644 InstarBot/Commands/TriggerBirthdaySystemCommand.cs create mode 100644 InstarBot/Embeds/InstarUnderageUserWarningEmbed.cs create mode 100644 InstarBot/Services/BirthdaySystem.cs create mode 100644 InstarBot/Services/FileSystemMetricService.cs create mode 100644 InstarBot/Services/IBirthdaySystem.cs diff --git a/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json b/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json index a8d4466..43b8301 100644 --- a/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json +++ b/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json @@ -1,6 +1,7 @@ { "Testing": true, "BotName": "Instar", + "BotUserID": "1113147583041392641", "TargetGuild": 985521318080413766, "TargetChannel": 985521318080413769, "StaffAnnounceChannel": 985521973113286667, @@ -59,6 +60,49 @@ } ] }, + "BirthdayConfig": { + "BirthdayRole": "1443067834145177750", + "BirthdayAnnounceChannel": "1114974872934809700", + "MinimumPermissibleAge": 13, + "AgeRoleMap": [ + { + "Age": 13, + "Role": "1121816062204330047" + }, + { + "Age": 14, + "Role": "1121815980818051112" + }, + { + "Age": 15, + "Role": "1121815979551363126" + }, + { + "Age": 16, + "Role": "1121815979043856484" + }, + { + "Age": 17, + "Role": "1121815977634578564" + }, + { + "Age": 18, + "Role": "1121815976837656727" + }, + { + "Age": 19, + "Role": "1121815975336104048" + }, + { + "Age": 20, + "Role": "1121815973197004880" + }, + { + "Age": 21, + "Role": "1121815941852954734" + } + ] + }, "Teams": [ { "InternalID": "ffcf94e3-3080-455a-82e2-7cd9ec7eaafd", diff --git a/InstarBot.Tests.Common/Models/TestChannel.cs b/InstarBot.Tests.Common/Models/TestChannel.cs index ea9224b..67158ef 100644 --- a/InstarBot.Tests.Common/Models/TestChannel.cs +++ b/InstarBot.Tests.Common/Models/TestChannel.cs @@ -239,7 +239,7 @@ public Task> GetActiveThreadsAsync(RequestOp public ChannelType ChannelType => throw new NotImplementedException(); - private readonly List _messages = []; + private readonly List _messages = []; public void AddMessage(IGuildUser user, string message) { @@ -248,7 +248,10 @@ public void AddMessage(IGuildUser user, string message) public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) { - throw new NotImplementedException(); + var msg = new TestMessage(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags, poll); + _messages.Add(msg); + + return Task.FromResult(msg as IUserMessage); } public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) diff --git a/InstarBot.Tests.Common/Models/TestGuild.cs b/InstarBot.Tests.Common/Models/TestGuild.cs index 5aa293d..6217a51 100644 --- a/InstarBot.Tests.Common/Models/TestGuild.cs +++ b/InstarBot.Tests.Common/Models/TestGuild.cs @@ -7,11 +7,12 @@ namespace InstarBot.Tests.Models; public class TestGuild : IInstarGuild { public ulong Id { get; init; } - public IEnumerable TextChannels { get; init; } = null!; + public IEnumerable TextChannels { get; init; } = [ ]; - public IEnumerable Roles { get; init; } = null!; - public IEnumerable Users { get; init; } = null!; + public IEnumerable Roles { get; init; } = null!; + + public List Users { get; init; } = []; public virtual ITextChannel GetTextChannel(ulong channelId) { @@ -21,5 +22,10 @@ public virtual ITextChannel GetTextChannel(ulong channelId) public virtual IRole GetRole(Snowflake roleId) { return Roles.First(n => n.Id.Equals(roleId)); - } + } + + public void AddUser(TestGuildUser user) + { + Users.Add(user); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestGuildUser.cs b/InstarBot.Tests.Common/Models/TestGuildUser.cs index 142177c..fea45a8 100644 --- a/InstarBot.Tests.Common/Models/TestGuildUser.cs +++ b/InstarBot.Tests.Common/Models/TestGuildUser.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Discord; +using Moq; #pragma warning disable CS8625 @@ -7,9 +8,9 @@ namespace InstarBot.Tests.Models; [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] [SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] -public sealed class TestGuildUser : IGuildUser +public class TestGuildUser : IGuildUser { - private readonly List _roleIds = null!; + private readonly List _roleIds = [ ]; public ulong Id { get; init; } public DateTimeOffset CreatedAt { get; set; } @@ -35,7 +36,9 @@ public sealed class TestGuildUser : IGuildUser public bool IsVideoing { get; set; } public DateTimeOffset? RequestToSpeakTimestamp { get; set; } - public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + private readonly Mock _dmChannelMock = new(); + + public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) { return string.Empty; } @@ -47,7 +50,7 @@ public string GetDefaultAvatarUrl() public Task CreateDMChannelAsync(RequestOptions options = null!) { - throw new NotImplementedException(); + return Task.FromResult(_dmChannelMock.Object); } public ChannelPermissions GetPermissions(IGuildChannel channel) @@ -159,7 +162,7 @@ public string GetAvatarDecorationUrl() public string GuildAvatarId { get; set; } = null!; public GuildPermissions GuildPermissions { get; set; } public IGuild Guild { get; set; } = null!; - public ulong GuildId { get; set; } + public ulong GuildId => TestUtilities.GuildID; public DateTimeOffset? PremiumSince { get; set; } public IReadOnlyCollection RoleIds diff --git a/InstarBot.Tests.Common/Models/TestMessage.cs b/InstarBot.Tests.Common/Models/TestMessage.cs index f79f5e4..ff675d6 100644 --- a/InstarBot.Tests.Common/Models/TestMessage.cs +++ b/InstarBot.Tests.Common/Models/TestMessage.cs @@ -1,9 +1,10 @@ using Discord; using PaxAndromeda.Instar; +using MessageProperties = Discord.MessageProperties; namespace InstarBot.Tests.Models; -public sealed class TestMessage : IMessage +public sealed class TestMessage : IUserMessage, IMessage { internal TestMessage(IUser user, string message) @@ -16,6 +17,23 @@ internal TestMessage(IUser user, string message) Content = message; } + public TestMessage(string text, bool isTTS, Embed? embed, RequestOptions? options, AllowedMentions? allowedMentions, MessageReference? messageReference, MessageComponent? components, ISticker[]? stickers, Embed[]? embeds, MessageFlags? flags, PollProperties? poll) + { + Content = text; + IsTTS = isTTS; + Flags= flags; + + var embedList = new List(); + + if (embed is not null) + embedList.Add(embed); + if (embeds is not null) + embedList.AddRange(embeds); + + Flags = flags; + Reference = messageReference; + } + public ulong Id { get; } public DateTimeOffset CreatedAt { get; } @@ -56,8 +74,9 @@ public IAsyncEnumerable> GetReactionUsersAsync(IEmote public MessageType Type => default; public MessageSource Source => default; - public bool IsTTS => false; - public bool IsPinned => false; + public bool IsTTS { get; set; } + + public bool IsPinned => false; public bool IsSuppressed => false; public bool MentionedEveryone => false; public string Content { get; } @@ -75,15 +94,59 @@ public IAsyncEnumerable> GetReactionUsersAsync(IEmote public IReadOnlyCollection MentionedUserIds => null!; public MessageActivity Activity => null!; public MessageApplication Application => null!; - public MessageReference Reference => null!; - public IReadOnlyDictionary Reactions => null!; + public MessageReference Reference { get; set; } + + public IReadOnlyDictionary Reactions => null!; public IReadOnlyCollection Components => null!; public IReadOnlyCollection Stickers => null!; - public MessageFlags? Flags => null; + public MessageFlags? Flags { get; set; } + public IMessageInteraction Interaction => null!; public MessageRoleSubscriptionData RoleSubscriptionData => null!; public PurchaseNotification PurchaseNotification => throw new NotImplementedException(); public MessageCallData? CallData => throw new NotImplementedException(); + public Task ModifyAsync(Action func, RequestOptions options = null) + { + throw new NotImplementedException(); + } + + public Task PinAsync(RequestOptions options = null) + { + throw new NotImplementedException(); + } + + public Task UnpinAsync(RequestOptions options = null) + { + throw new NotImplementedException(); + } + + public Task CrosspostAsync(RequestOptions options = null) + { + throw new NotImplementedException(); + } + + public string Resolve(TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name, TagHandling roleHandling = TagHandling.Name, + TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name) + { + throw new NotImplementedException(); + } + + public Task EndPollAsync(RequestOptions options) + { + throw new NotImplementedException(); + } + + public IAsyncEnumerable> GetPollAnswerVotersAsync(uint answerId, int? limit = null, ulong? afterId = null, + RequestOptions options = null) + { + throw new NotImplementedException(); + } + + public MessageResolvedData ResolvedData { get; set; } + public IUserMessage ReferencedMessage { get; set; } + public IMessageInteractionMetadata InteractionMetadata { get; set; } + public IReadOnlyCollection ForwardedMessages { get; set; } + public Poll? Poll { get; set; } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs b/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs index 4a6c2f0..b52aade 100644 --- a/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs +++ b/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs @@ -16,4 +16,9 @@ public virtual MembershipEligibility CheckEligibility(InstarDynamicConfiguration { throw new NotImplementedException(); } + + public Task Initialize() + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockDiscordService.cs b/InstarBot.Tests.Common/Services/MockDiscordService.cs index da1ca6e..96ad258 100644 --- a/InstarBot.Tests.Common/Services/MockDiscordService.cs +++ b/InstarBot.Tests.Common/Services/MockDiscordService.cs @@ -9,8 +9,9 @@ namespace InstarBot.Tests.Services; public sealed class MockDiscordService : IDiscordService { - private readonly IInstarGuild _guild; - private readonly AsyncEvent _userJoinedEvent = new(); + private IInstarGuild _guild; + private readonly AsyncEvent _userJoinedEvent = new(); + private readonly AsyncEvent _userLeftEvent = new(); private readonly AsyncEvent _userUpdatedEvent = new(); private readonly AsyncEvent _messageReceivedEvent = new(); private readonly AsyncEvent _messageDeletedEvent = new(); @@ -21,6 +22,12 @@ public event Func UserJoined remove => _userJoinedEvent.Remove(value); } + public event Func UserLeft + { + add => _userLeftEvent.Add(value); + remove => _userLeftEvent.Remove(value); + } + public event Func UserUpdated { add => _userUpdatedEvent.Add(value); @@ -44,6 +51,12 @@ internal MockDiscordService(IInstarGuild guild) _guild = guild; } + public IInstarGuild Guild + { + get => _guild; + set => _guild = value; + } + public Task Start(IServiceProvider provider) { return Task.CompletedTask; @@ -56,7 +69,7 @@ public IInstarGuild GetGuild() public Task> GetAllUsers() { - return Task.FromResult(((TestGuild)_guild).Users); + return Task.FromResult(((TestGuild)_guild).Users.AsEnumerable()); } public Task GetChannel(Snowflake channelId) @@ -72,6 +85,21 @@ public async IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime yield return message; } + public IGuildUser? GetUser(Snowflake snowflake) + { + return ((TestGuild) _guild).Users.FirstOrDefault(n => n.Id.Equals(snowflake.ID)); + } + + public IEnumerable GetAllUsersWithRole(Snowflake roleId) + { + return ((TestGuild) _guild).Users.Where(n => n.RoleIds.Contains(roleId.ID)); + } + + public Task SyncUsers() + { + return Task.CompletedTask; + } + public async Task TriggerUserJoined(IGuildUser user) { await _userJoinedEvent.Invoke(user); @@ -87,4 +115,10 @@ public async Task TriggerMessageReceived(IMessage message) { await _messageReceivedEvent.Invoke(message); } + + public void AddUser(TestGuildUser user) + { + var guild = _guild as TestGuild; + guild?.AddUser(user); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs index 10bb92d..0aa0446 100644 --- a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs +++ b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs @@ -44,6 +44,15 @@ public MockInstarDDBService(IEnumerable preload) public void Register(InstarUserData data) { + // Quick check to make sure we're not overriding an existing exception + try + { + _internalMock.Object.GetUserAsync(Snowflake.Generate()); + } catch + { + return; + } + _internalMock .Setup(n => n.GetUserAsync(data.UserID!)) .ReturnsAsync(new InstarDatabaseEntry(_ddbContextMock.Object, data)); @@ -73,7 +82,6 @@ public async Task> GetOrCreateUserAsync(IGui if (result is not null) return result; - // Gotta set up the mock await CreateUserAsync(InstarUserData.CreateFrom(user)); result = await _internalMock.Object.GetUserAsync(user.Id); @@ -108,7 +116,14 @@ public Task CreateUserAsync(InstarUserData data) return Task.CompletedTask; } - /// + public async Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness) + { + var result = await _internalMock.Object.GetUsersByBirthday(birthdate, fuzziness); + + return result; + } + + /// /// Configures a setup for the specified expression on the mocked interface, allowing /// control over the behavior of the mock for the given member. /// diff --git a/InstarBot.Tests.Common/TestContext.cs b/InstarBot.Tests.Common/TestContext.cs index 866f817..515ed1f 100644 --- a/InstarBot.Tests.Common/TestContext.cs +++ b/InstarBot.Tests.Common/TestContext.cs @@ -12,7 +12,7 @@ public sealed class TestContext public const ulong ChannelID = 1420070400200; public const ulong GuildID = 1420070400300; - public List UserRoles { get; init; } = []; + public HashSet UserRoles { get; init; } = []; public Action EmbedCallback { get; init; } = _ => { }; @@ -20,13 +20,16 @@ public sealed class TestContext public List GuildUsers { get; } = []; - public Dictionary Channels { get; } = []; + public Dictionary Channels { get; } = []; public Dictionary Roles { get; } = []; public Dictionary> Warnings { get; } = []; public Dictionary> Caselogs { get; } = []; - public bool InhibitGaius { get; set; } + public Dictionary> UserRolesMap { get; } = []; + + public bool InhibitGaius { get; set; } + public Mock DMChannelMock { get ; set ; } public void AddWarning(Snowflake userId, Warning warning) { @@ -50,7 +53,7 @@ public void AddChannel(Snowflake channelId) Channels.Add(channelId, new TestChannel(channelId)); } - public TestChannel GetChannel(Snowflake channelId) + public ITextChannel GetChannel(Snowflake channelId) { return Channels[channelId]; } diff --git a/InstarBot.Tests.Common/TestUtilities.cs b/InstarBot.Tests.Common/TestUtilities.cs index 9fdcace..30d14cc 100644 --- a/InstarBot.Tests.Common/TestUtilities.cs +++ b/InstarBot.Tests.Common/TestUtilities.cs @@ -23,6 +23,16 @@ public static class TestUtilities private static IConfiguration? _config; private static IDynamicConfigService? _dynamicConfig; + public static ulong GuildID + { + get + { + var cfg = GetTestConfiguration(); + + return ulong.Parse(cfg.GetValue("TargetGuild") ?? throw new ConfigurationException("Expected TargetGuild to be set")); + } + } + private static IConfiguration GetTestConfiguration() { if (_config is not null) @@ -127,7 +137,7 @@ public static void VerifyEmbed(Mock command, EmbedVerifier verifier, strin } public static void VerifyChannelMessage(Mock channel, string format, bool ephemeral = false, bool partial = false) - where T : class, ITextChannel + where T : class, IMessageChannel { channel.Verify(c => c.SendMessageAsync( It.Is(s => MatchesFormat(s, format, partial)), @@ -242,25 +252,45 @@ private static Mock SetupGuildMock(TestContext? context) return guildMock; } - public static Mock SetupUserMock(ulong userId) - where T : class, IUser - { - var userMock = new Mock(); - userMock.Setup(n => n.Id).Returns(userId); + public static Mock SetupUserMock(ulong userId) + where T : class, IUser + { + var userMock = new Mock(); + userMock.Setup(n => n.Id).Returns(userId); - return userMock; - } + return userMock; + } - private static Mock SetupUserMock(TestContext? context) - where T : class, IUser - { - var userMock = SetupUserMock(context!.UserID); + private static Mock SetupUserMock(TestContext? context) + where T : class, IUser + { + var userMock = SetupUserMock(context!.UserID); - if (typeof(T) == typeof(IGuildUser)) - userMock.As().Setup(n => n.RoleIds).Returns(context.UserRoles.Select(n => n.ID).ToList); + var dmChannelMock = new Mock(); - return userMock; - } + context.DMChannelMock = dmChannelMock; + userMock.Setup(n => n.CreateDMChannelAsync(It.IsAny())) + .ReturnsAsync(dmChannelMock.Object); + + if (typeof(T) != typeof(IGuildUser)) return userMock; + + userMock.As().Setup(n => n.RoleIds).Returns(context.UserRoles.Select(n => n.ID).ToList); + + userMock.As().Setup(n => n.AddRoleAsync(It.IsAny(), It.IsAny())) + .Callback((ulong roleId, RequestOptions _) => + { + context.UserRoles.Add(roleId); + }) + .Returns(Task.CompletedTask); + + userMock.As().Setup(n => n.RemoveRoleAsync(It.IsAny(), It.IsAny())) + .Callback((ulong roleId, RequestOptions _) => + { + context.UserRoles.Remove(roleId); + }).Returns(Task.CompletedTask); + + return userMock; + } public static Mock SetupChannelMock(ulong channelId) where T : class, IChannel diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs index 30259be..fe04992 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs @@ -27,6 +27,8 @@ private static async Task Setup(bool setupAMH = false) var mockDDB = new MockInstarDDBService(); var mockMetrics = new MockMetricService(); + + var user = new TestGuildUser { Id = userID, @@ -60,7 +62,7 @@ private static async Task Setup(bool setupAMH = false) } var commandMock = TestUtilities.SetupCommandMock( - () => new AutoMemberHoldCommand(mockDDB, TestUtilities.GetDynamicConfiguration(), mockMetrics), + () => new AutoMemberHoldCommand(mockDDB, TestUtilities.GetDynamicConfiguration(), mockMetrics, TimeProvider.System), new TestContext { UserID = modID diff --git a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs index 6fd33dc..13dc002 100644 --- a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs @@ -7,7 +7,6 @@ using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.DynamoModels; -using PaxAndromeda.Instar.Services; using Xunit; namespace InstarBot.Tests.Integration.Interactions; @@ -57,7 +56,7 @@ private static async Task> SetupCommandMock(CheckE new TestContext { UserID = userId, - UserRoles = context.Roles.Select(n => new Snowflake(n)).ToList() + UserRoles = context.Roles.Select(n => new Snowflake(n)).ToHashSet() }); context.DDB = mockDDB; @@ -181,7 +180,7 @@ public static async Task CheckEligibilityCommand_DDBError_ShouldEmitValidMessage [Theory] [InlineData(MembershipEligibility.MissingRoles)] [InlineData(MembershipEligibility.MissingIntroduction)] - [InlineData(MembershipEligibility.TooYoung)] + [InlineData(MembershipEligibility.InadequateTenure)] [InlineData(MembershipEligibility.PunishmentReceived)] [InlineData(MembershipEligibility.NotEnoughMessages)] public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMessage(MembershipEligibility eligibility) @@ -191,7 +190,7 @@ public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMes { { MembershipEligibility.MissingRoles, Strings.Command_CheckEligibility_MessagesEligibility }, { MembershipEligibility.MissingIntroduction, Strings.Command_CheckEligibility_IntroductionEligibility }, - { MembershipEligibility.TooYoung, Strings.Command_CheckEligibility_JoinAgeEligibility }, + { MembershipEligibility.InadequateTenure, Strings.Command_CheckEligibility_JoinAgeEligibility }, { MembershipEligibility.PunishmentReceived, Strings.Command_CheckEligibility_ModActionsEligibility }, { MembershipEligibility.NotEnoughMessages, Strings.Command_CheckEligibility_MessagesEligibility }, }; @@ -200,7 +199,7 @@ public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMes { { MembershipEligibility.MissingRoles, Strings.Command_CheckEligibility_MissingItem_Role }, { MembershipEligibility.MissingIntroduction, Strings.Command_CheckEligibility_MissingItem_Introduction }, - { MembershipEligibility.TooYoung, Strings.Command_CheckEligibility_MissingItem_TooYoung }, + { MembershipEligibility.InadequateTenure, Strings.Command_CheckEligibility_MissingItem_TooYoung }, { MembershipEligibility.PunishmentReceived, Strings.Command_CheckEligibility_MissingItem_PunishmentReceived }, { MembershipEligibility.NotEnoughMessages, Strings.Command_CheckEligibility_MissingItem_Messages }, }; @@ -218,7 +217,7 @@ public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMes var ctx = new CheckEligibilityCommandTestContext( false, [ NewMemberRole ], - MembershipEligibility.NotEligible | eligibility); + eligibility); var mock = await SetupCommandMock(ctx); @@ -253,7 +252,7 @@ public static async Task CheckOtherEligibility_WithEligibleMember_ShouldEmitVali public static async Task CheckOtherEligibility_WithIneligibleMember_ShouldEmitValidEmbed() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.NotEligible | MembershipEligibility.MissingRoles); + var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.MissingRoles); var mock = await SetupCommandMock(ctx); ctx.User.Should().NotBeNull(); @@ -274,7 +273,7 @@ public static async Task CheckOtherEligibility_WithIneligibleMember_ShouldEmitVa public static async Task CheckOtherEligibility_WithAMHedMember_ShouldEmitValidEmbed() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(true, [ NewMemberRole ], MembershipEligibility.NotEligible | MembershipEligibility.MissingRoles); + var ctx = new CheckEligibilityCommandTestContext(true, [ NewMemberRole ], MembershipEligibility.MissingRoles); var mock = await SetupCommandMock(ctx); ctx.User.Should().NotBeNull(); @@ -295,7 +294,7 @@ public static async Task CheckOtherEligibility_WithAMHedMember_ShouldEmitValidEm public static async Task CheckOtherEligibility_WithDynamoError_ShouldEmitValidEmbed() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.NotEligible | MembershipEligibility.MissingRoles); + var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.MissingRoles); var mock = await SetupCommandMock(ctx); ctx.DDB.Setup(n => n.GetUserAsync(It.IsAny())) diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index 1bb380c..260ae3b 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -2,7 +2,6 @@ using InstarBot.Tests.Models; using InstarBot.Tests.Services; using Moq; -using Moq.Protected; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; using Xunit; diff --git a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs new file mode 100644 index 0000000..cf5c450 --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs @@ -0,0 +1,133 @@ +using Discord; +using FluentAssertions; +using InstarBot.Tests.Services; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Services; +using Xunit; +using Assert = Xunit.Assert; + +namespace InstarBot.Tests.Integration.Interactions; + +public static class ResetBirthdayCommandTests +{ + private static async Task<(IInstarDDBService, Mock, IGuildUser, TestContext, InstarDynamicConfiguration cfg)> SetupMocks(Birthday? userBirthday = null, bool throwError = false, bool skipDbInsert = false) + { + TestUtilities.SetupLogging(); + + var ddbService = TestUtilities.GetServices().GetService(); + var cfgService = TestUtilities.GetDynamicConfiguration(); + var cfg = await cfgService.GetConfig(); + var userId = Snowflake.Generate(); + + if (throwError && ddbService is MockInstarDDBService mockDDB) + { + mockDDB.Setup(n => n.GetUserAsync(It.IsAny())).Throws(); + + // assert that we're actually throwing an exception + await Assert.ThrowsAsync(async () => await ddbService.GetUserAsync(userId)); + } + + var testContext = new TestContext + { + UserID = userId + }; + + var cmd = TestUtilities.SetupCommandMock(() => new ResetBirthdayCommand(ddbService!, cfgService), testContext); + + await cmd.Object.Context.User!.AddRoleAsync(cfg.NewMemberRoleID); + + cmd.Setup(n => n.Context.User!.GuildId).Returns(TestUtilities.GuildID); + + if (!skipDbInsert) + ((MockInstarDDBService) ddbService!).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); + + ddbService.Should().NotBeNull(); + + if (userBirthday is null) + return (ddbService, cmd, cmd.Object.Context.User!, testContext, cfg); + + var dbUser = await ddbService.GetUserAsync(userId); + dbUser!.Data.Birthday = userBirthday; + dbUser.Data.Birthdate = userBirthday.Key; + await dbUser.UpdateAsync(); + + return (ddbService, cmd, cmd.Object.Context.User!, testContext, cfg); + } + + [Fact] + public static async Task ResetBirthday_WithEligibleUser_ShouldHaveBirthdayReset() + { + // Arrange + var (ddb, cmd, user, ctx, _) = await SetupMocks(userBirthday: new Birthday(new DateTime(2000, 1, 1), TimeProvider.System)); + + // Act + await cmd.Object.ResetBirthday(user); + + // Assert + var dbUser = await ddb.GetUserAsync(user.Id); + dbUser.Should().NotBeNull(); + dbUser.Data.Birthday.Should().BeNull(); + dbUser.Data.Birthdate.Should().BeNull(); + + TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Success, ephemeral: true); + TestUtilities.VerifyChannelMessage(ctx.DMChannelMock, Strings.Command_ResetBirthday_EndUserNotification); + } + + [Fact] + public static async Task ResetBirthday_UserNotFound_ShouldEmitError() + { + // Arrange + var (ddb, cmd, user, ctx, _) = await SetupMocks(skipDbInsert: true); + + // Act + await cmd.Object.ResetBirthday(user); + + // Assert + var dbUser = await ddb.GetUserAsync(user.Id); + dbUser.Should().BeNull(); + + TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Error_UserNotFound, ephemeral: true); + } + + [Fact] + public static async Task ResetBirthday_UserHasBirthdayRole_ShouldRemoveRole() + { + // Arrange + var (ddb, cmd, user, ctx, cfg) = await SetupMocks(userBirthday: new Birthday(new DateTime(2000, 1, 1), TimeProvider.System)); + + await user.AddRoleAsync(cfg.BirthdayConfig.BirthdayRole); + user.RoleIds.Should().Contain(cfg.BirthdayConfig.BirthdayRole); + + // Act + await cmd.Object.ResetBirthday(user); + + // Assert + var dbUser = await ddb.GetUserAsync(user.Id); + dbUser.Should().NotBeNull(); + dbUser.Data.Birthday.Should().BeNull(); + dbUser.Data.Birthdate.Should().BeNull(); + + user.RoleIds.Should().NotContain(cfg.BirthdayConfig.BirthdayRole); + + TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Success, ephemeral: true); + TestUtilities.VerifyChannelMessage(ctx.DMChannelMock, Strings.Command_ResetBirthday_EndUserNotification); + } + + [Fact] + public static async Task ResetBirthday_WithDBError_ShouldEmitError() + { + // Arrange + var (ddb, cmd, user, ctx, _) = await SetupMocks(throwError: true); + + // Act + await cmd.Object.ResetBirthday(user); + + // Assert + TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Error_Unknown, ephemeral: true); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs index f97203a..36989a4 100644 --- a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -1,9 +1,11 @@ -using FluentAssertions; +using Discord; +using FluentAssertions; using InstarBot.Tests.Services; using Microsoft.Extensions.DependencyInjection; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; using Xunit; @@ -12,25 +14,71 @@ namespace InstarBot.Tests.Integration.Interactions; public static class SetBirthdayCommandTests { - private static (IInstarDDBService, Mock) SetupMocks(SetBirthdayContext context) + private static async Task<(IInstarDDBService, Mock, InstarDynamicConfiguration)> SetupMocks(SetBirthdayContext context, DateTime? timeOverride = null, bool throwError = false) { TestUtilities.SetupLogging(); - - var ddbService = TestUtilities.GetServices().GetService(); - var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService!, new MockMetricService()), new TestContext - { - UserID = context.User.ID - }); - - ((MockInstarDDBService) ddbService!).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); + + var timeProvider = TimeProvider.System; + if (timeOverride is not null) + { + var timeProviderMock = new Mock(); + timeProviderMock.Setup(n => n.GetUtcNow()).Returns(new DateTimeOffset((DateTime)timeOverride)); + timeProvider = timeProviderMock.Object; + } + + var staffAnnounceChannelMock = new Mock(); + var birthdayAnnounceChannelMock = new Mock(); + context.StaffAnnounceChannel = staffAnnounceChannelMock; + context.BirthdayAnnounceChannel = birthdayAnnounceChannelMock; + + var ddbService = TestUtilities.GetServices().GetService(); + var cfgService = TestUtilities.GetDynamicConfiguration(); + var cfg = await cfgService.GetConfig(); + + var guildMock = new Mock(); + guildMock.Setup(n => n.GetTextChannel(cfg.StaffAnnounceChannel)).Returns(staffAnnounceChannelMock.Object); + guildMock.Setup(n => n.GetTextChannel(cfg.BirthdayConfig.BirthdayAnnounceChannel)).Returns(birthdayAnnounceChannelMock.Object); + + var testContext = new TestContext + { + UserID = context.UserID.ID, + Channels = + { + { cfg.StaffAnnounceChannel, staffAnnounceChannelMock.Object }, + { cfg.BirthdayConfig.BirthdayAnnounceChannel, birthdayAnnounceChannelMock.Object } + } + }; + + var discord = TestUtilities.SetupDiscordService(testContext); + if (discord is MockDiscordService mockDiscord) + { + mockDiscord.Guild = guildMock.Object; + } + + var birthdaySystem = new BirthdaySystem(cfgService, discord, ddbService, new MockMetricService(), timeProvider); + + if (throwError && ddbService is MockInstarDDBService mockDDB) + mockDDB.Setup(n => n.GetOrCreateUserAsync(It.IsAny())).Throws(); + + var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService, TestUtilities.GetDynamicConfiguration(), new MockMetricService(), birthdaySystem, timeProvider), testContext); + + await cmd.Object.Context.User!.AddRoleAsync(cfg.NewMemberRoleID); + + cmd.Setup(n => n.Context.User!.GuildId).Returns(TestUtilities.GuildID); + + context.User = cmd.Object.Context.User!; + + cmd.Setup(n => n.Context.Guild).Returns(guildMock.Object); + + ((MockInstarDDBService) ddbService).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); ddbService.Should().NotBeNull(); - return (ddbService, cmd); + return (ddbService, cmd, cfg); } - [Theory(DisplayName = "User should be able to set their birthday when providing a valid date.")] + [Theory(DisplayName = "UserID should be able to set their birthday when providing a valid date.")] [InlineData(1992, 7, 21, 0)] [InlineData(1992, 7, 21, -7)] [InlineData(1992, 7, 21, 7)] @@ -42,7 +90,7 @@ public static async Task SetBirthdayCommand_WithValidDate_ShouldSetCorrectly(int // Arrange var context = new SetBirthdayContext(Snowflake.Generate(), year, month, day, timezone); - var (ddb, cmd) = SetupMocks(context); + var (ddb, cmd, _) = await SetupMocks(context); // Act await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); @@ -50,9 +98,11 @@ public static async Task SetBirthdayCommand_WithValidDate_ShouldSetCorrectly(int // Assert var date = context.ToDateTime(); - var ddbUser = await ddb.GetUserAsync(context.User.ID); - ddbUser!.Data.Birthday.Should().Be(date.UtcDateTime); - TestUtilities.VerifyMessage(cmd, $"Your birthday was set to {date.DateTime:dddd, MMMM d, yyy}.", true); + var ddbUser = await ddb.GetUserAsync(context.UserID.ID); + ddbUser!.Data.Birthday.Should().NotBeNull(); + ddbUser.Data.Birthday.Birthdate.UtcDateTime.Should().Be(date.UtcDateTime); + ddbUser.Data.Birthdate.Should().Be(date.UtcDateTime.ToString("MMddHHmm")); + TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Success, true); } [Theory(DisplayName = "Attempting to set an invalid day or month number should emit an error message.")] @@ -67,7 +117,7 @@ public static async Task SetBirthdayCommand_WithInvalidDate_ShouldReturnError(in // Arrange var context = new SetBirthdayContext(Snowflake.Generate(), year, month, day); - var (_, cmd) = SetupMocks(context); + var (_, cmd, _) = await SetupMocks(context); // Act await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); @@ -76,7 +126,7 @@ public static async Task SetBirthdayCommand_WithInvalidDate_ShouldReturnError(in if (month is < 0 or > 12) { TestUtilities.VerifyMessage(cmd, - "There are only 12 months in a year. Your birthday was not set.", true); + Strings.Command_SetBirthday_MonthsOutOfRange, true); } else { @@ -85,27 +135,133 @@ public static async Task SetBirthdayCommand_WithInvalidDate_ShouldReturnError(in // Assert TestUtilities.VerifyMessage(cmd, - $"There are only {daysInMonth} days in {date:MMMM yyy}. Your birthday was not set.", true); + Strings.Command_SetBirthday_DaysInMonthOutOfRange, true); } - } + } - [Fact(DisplayName = "Attempting to set a birthday in the future should emit an error message.")] - public static async Task SetBirthdayCommand_WithDateInFuture_ShouldReturnError() - { - // Arrange - // Note: Update this in the year 9,999 - var context = new SetBirthdayContext(Snowflake.Generate(), 9999, 1, 1); + [Fact(DisplayName = "Attempting to set a birthday in the future should emit an error message.")] + public static async Task SetBirthdayCommand_WithDateInFuture_ShouldReturnError() + { + // Arrange + // Note: Update this in the year 9,999 + var context = new SetBirthdayContext(Snowflake.Generate(), 9999, 1, 1); - var (_, cmd) = SetupMocks(context); + var (_, cmd, _) = await SetupMocks(context); - // Act - await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); + // Act + await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); - // Assert - TestUtilities.VerifyMessage(cmd, "You are not a time traveler. Your birthday was not set.", true); - } + // Assert + TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_NotTimeTraveler, true); + } + + [Fact(DisplayName = "Attempting to set a birthday when user has already set one should emit an error message.")] + public static async Task SetBirthdayCommand_BirthdayAlreadyExists_ShouldReturnError() + { + // Arrange + // Note: Update this in the year 9,999 + var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); + + var (ddb, cmd, _) = await SetupMocks(context); + + var dbUser = await ddb.GetOrCreateUserAsync(context.User); + dbUser.Data.Birthday = new Birthday(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc, TimeProvider.System); + dbUser.Data.Birthdate = dbUser.Data.Birthday.Key; + await dbUser.UpdateAsync(); + + + // Act + await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); - private record SetBirthdayContext(Snowflake User, int Year, int Month, int Day, int TimeZone = 0) + // Assert + TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Error_AlreadySet, true); + } + + [Fact(DisplayName = "An exception should return a message.")] + public static async Task SetBirthdayCommand_WithException_ShouldPromptUserToTryAgainLater() + { + // Arrange + // Note: Update this in the year 9,999 + var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); + + var (_, cmd, _) = await SetupMocks(context, throwError: true); + + // Act + await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + + // Assert + TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Error_CouldNotSetBirthday, true); + } + + [Fact(DisplayName = "Attempting to set an underage birthday should result in an AMH and staff notification.")] + public static async Task SetBirthdayCommand_WithUnderage_ShouldNotifyStaff() + { + // Arrange + // Note: Update this in the year 9,999 + var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); + + var (ddb, cmd, cfg) = await SetupMocks(context, new DateTime(2010, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + // Act + await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + + // Assert + var date = context.ToDateTime(); + + var ddbUser = await ddb.GetUserAsync(context.UserID.ID); + ddbUser!.Data.Birthday.Should().NotBeNull(); + ddbUser.Data.Birthday.Birthdate.UtcDateTime.Should().Be(date.UtcDateTime); + ddbUser.Data.Birthdate.Should().Be(date.UtcDateTime.ToString("MMddHHmm")); + + ddbUser.Data.AutoMemberHoldRecord.Should().NotBeNull(); + ddbUser.Data.AutoMemberHoldRecord!.ModeratorID.Should().Be(cfg.BotUserID); + + TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Success, true); + + var staffAnnounceChannel = cmd.Object.Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel); + staffAnnounceChannel.Should().NotBeNull(); + + + // Verify embed + var embedVerifier = EmbedVerifier.Builder() + .WithDescription(Strings.Embed_UnderageUser_WarningTemplate_NewMember).Build(); + + TestUtilities.VerifyChannelEmbed(context.StaffAnnounceChannel, embedVerifier, $"<@&{cfg.StaffRoleID}>"); + } + + [Fact(DisplayName = "Attempting to set a birthday to today should grant the birthday role.")] + public static async Task SetBirthdayCommand_BirthdayIsToday_ShouldGrantBirthdayRoles() + { + // Arrange + // Note: Update this in the year 9,999 + var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); + + var (ddb, cmd, cfg) = await SetupMocks(context, new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + // Act + await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + + // Assert + var date = context.ToDateTime(); + + context.User.RoleIds.Should().Contain(cfg.BirthdayConfig.BirthdayRole); + + var ddbUser = await ddb.GetUserAsync(context.UserID.ID); + ddbUser!.Data.Birthday.Should().NotBeNull(); + ddbUser.Data.Birthday.Birthdate.UtcDateTime.Should().Be(date.UtcDateTime); + ddbUser.Data.Birthdate.Should().Be(date.UtcDateTime.ToString("MMddHHmm")); + + ddbUser.Data.AutoMemberHoldRecord.Should().BeNull(); + + TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Success, true); + + var birthdayAnnounceChannel = cmd.Object.Context.Guild.GetTextChannel(cfg.BirthdayConfig.BirthdayAnnounceChannel); + birthdayAnnounceChannel.Should().NotBeNull(); + + TestUtilities.VerifyChannelMessage(context.BirthdayAnnounceChannel, Strings.Birthday_Announcement); + } + + private record SetBirthdayContext(Snowflake UserID, int Year, int Month, int Day, int TimeZone = 0) { public DateTimeOffset ToDateTime() { @@ -113,6 +269,10 @@ public DateTimeOffset ToDateTime() var timeZone = new DateTimeOffset(unspecifiedDate, TimeSpan.FromHours(TimeZone)); return timeZone; - } + } + + public Mock StaffAnnounceChannel { get; set; } + public IGuildUser User { get; set; } + public Mock BirthdayAnnounceChannel { get ; set ; } } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs similarity index 84% rename from InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs rename to InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs index 7f493d2..40876a5 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs +++ b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs @@ -9,7 +9,7 @@ using PaxAndromeda.Instar.Services; using Xunit; -namespace InstarBot.Tests.Integration.Interactions; +namespace InstarBot.Tests.Integration.Services; public static class AutoMemberSystemTests { @@ -20,7 +20,7 @@ public static class AutoMemberSystemTests private static readonly Snowflake SheHer = new(796578609535647765); private static readonly Snowflake AutoMemberHold = new(966434762032054282); - private static AutoMemberSystem SetupTest(AutoMemberSystemContext scenarioContext) + private static async Task SetupTest(AutoMemberSystemContext scenarioContext) { var testContext = scenarioContext.TestContext; @@ -65,15 +65,16 @@ private static AutoMemberSystem SetupTest(AutoMemberSystemContext scenarioContex testContext.AddChannel(genericChannel); if (postedIntro) - testContext.GetChannel(amsConfig.IntroductionChannel).AddMessage(user, "Some text"); + ((TestChannel) testContext.GetChannel(amsConfig.IntroductionChannel)).AddMessage(user, "Some text"); for (var i = 0; i < messagesLast24Hours; i++) - testContext.GetChannel(genericChannel).AddMessage(user, "Some text"); + ((TestChannel)testContext.GetChannel(genericChannel)).AddMessage(user, "Some text"); - var ams = new AutoMemberSystem(config, discordService, gaiusApiService, ddbService, new MockMetricService()); + var ams = new AutoMemberSystem(config, discordService, gaiusApiService, ddbService, new MockMetricService(), TimeProvider.System); + await ams.Initialize(); - scenarioContext.User = user; + scenarioContext.User = user; scenarioContext.DynamoService = ddbService; return ams; @@ -91,9 +92,9 @@ public static async Task AutoMemberSystem_EligibleUser_ShouldBeGrantedMembership .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); - // Act + // Act await ams.RunAsync(); // Assert @@ -112,7 +113,7 @@ public static async Task AutoMemberSystem_EligibleUserWithAMH_ShouldNotBeGranted .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -133,7 +134,7 @@ public static async Task AutoMemberSystem_NewUser_ShouldNotBeGrantedMembership() .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -153,7 +154,7 @@ public static async Task AutoMemberSystem_InactiveUser_ShouldNotBeGrantedMembers .WithMessages(10) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -173,7 +174,7 @@ public static async Task AutoMemberSystem_Member_ShouldNotBeChanged() .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -192,7 +193,7 @@ public static async Task AutoMemberSystem_NoIntroduction_ShouldNotBeGrantedMembe .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -212,7 +213,7 @@ public static async Task AutoMemberSystem_NoAgeRole_ShouldNotBeGrantedMembership .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -232,7 +233,7 @@ public static async Task AutoMemberSystem_NoGenderRole_ShouldNotBeGrantedMembers .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -252,7 +253,7 @@ public static async Task AutoMemberSystem_NoPronounRole_ShouldNotBeGrantedMember .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -273,7 +274,7 @@ public static async Task AutoMemberSystem_UserWithGaiusWarning_ShouldNotBeGrante .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -282,28 +283,49 @@ public static async Task AutoMemberSystem_UserWithGaiusWarning_ShouldNotBeGrante context.AssertNotMember(); } - [Fact(DisplayName = "A user with a caselog should not be granted membership")] - public static async Task AutoMemberSystem_UserWithGaiusCaselog_ShouldNotBeGrantedMembership() - { - // Arrange - var context = await AutoMemberSystemContext.Builder() - .Joined(TimeSpan.FromHours(36)) - .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) - .HasPostedIntroduction() - .HasBeenPunished() - .WithMessages(100) - .Build(); + [Fact(DisplayName = "A user with a caselog should not be granted membership")] + public static async Task AutoMemberSystem_UserWithGaiusCaselog_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .HasBeenPunished() + .WithMessages(100) + .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); - // Assert - context.AssertNotMember(); - } + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "A user with a join age auto kick should be granted membership")] + public static async Task AutoMemberSystem_UserWithJoinAgeKick_ShouldBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .HasBeenPunished(true) + .WithMessages(100) + .Build(); - [Fact(DisplayName = "A user should be granted membership if Gaius is unavailable")] + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertMember(); + } + + [Fact(DisplayName = "A user should be granted membership if Gaius is unavailable")] public static async Task AutoMemberSystem_GaiusIsUnavailable_ShouldBeGrantedMembership() { // Arrange @@ -315,7 +337,7 @@ public static async Task AutoMemberSystem_GaiusIsUnavailable_ShouldBeGrantedMemb .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -337,7 +359,7 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe .WithMessages(100) .Build(); - SetupTest(context); + await SetupTest(context); // Act var service = context.DiscordService as MockDiscordService; @@ -356,7 +378,7 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflectedInDynamo() { // Arrange - const string NewUsername = "fred"; + const string newUsername = "fred"; var context = await AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(1)) @@ -367,7 +389,7 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte .WithMessages(100) .Build(); - SetupTest(context); + await SetupTest(context); // Make sure the user is in the database context.DynamoService.Should().NotBeNull(because: "Test is invalid if DynamoService is not set"); @@ -377,7 +399,7 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte MockDiscordService mds = (MockDiscordService) context.DiscordService!; var newUser = context.User!.Clone(); - newUser.Username = NewUsername; + newUser.Username = newUsername; await mds.TriggerUserUpdated(new UserUpdatedEventArgs(context.UserID, context.User, newUser)); @@ -385,11 +407,11 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte var ddbUser = await context.DynamoService.GetUserAsync(context.UserID); ddbUser.Should().NotBeNull(); - ddbUser.Data.Username.Should().Be(NewUsername); + ddbUser.Data.Username.Should().Be(newUsername); ddbUser.Data.Usernames.Should().NotBeNull(); ddbUser.Data.Usernames.Count.Should().Be(2); - ddbUser.Data.Usernames.Should().Contain(n => n.Data != null && n.Data.Equals(NewUsername, StringComparison.Ordinal)); + ddbUser.Data.Usernames.Should().Contain(n => n.Data != null && n.Data.Equals(newUsername, StringComparison.Ordinal)); } [Fact(DisplayName = "A user should be created in DynamoDB if they're eligible for membership but missing in DDB")] @@ -405,7 +427,7 @@ public static async Task AutoMemberSystem_MemberEligibleButMissingInDDB_ShouldBe .SuppressDDBEntry() .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -459,9 +481,10 @@ private class AutoMemberSystemContextBuilder private Snowflake[]? _roles; private bool _postedIntroduction; private int _messagesLast24Hours; - private bool _gaiusAvailable = true; - private bool _gaiusPunished; - private bool _gaiusWarned; + private bool _gaiusAvailable = true; + private bool _gaiusPunished; + private bool _joinAgeKick; + private bool _gaiusWarned; private int _firstJoinTime; private bool _grantedMembershipBefore; private bool _suppressDDB; @@ -496,10 +519,12 @@ public AutoMemberSystemContextBuilder InhibitGaius() return this; } - public AutoMemberSystemContextBuilder HasBeenPunished() + public AutoMemberSystemContextBuilder HasBeenPunished(bool isJoinAgeKick = false) { _gaiusPunished = true; - return this; + _joinAgeKick = isJoinAgeKick; + + return this; } public AutoMemberSystemContextBuilder HasBeenWarned() @@ -541,8 +566,8 @@ public async Task Build() { testContext.AddCaselog(userId, new Caselog { - Type = CaselogType.Mute, - Reason = "TEST PUNISHMENT", + Type = _joinAgeKick ? CaselogType.Kick : CaselogType.Mute, + Reason = _joinAgeKick ? "Join age punishment" : "TEST PUNISHMENT", ModID = Snowflake.Generate(), UserID = userId }); diff --git a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs new file mode 100644 index 0000000..6ef494e --- /dev/null +++ b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs @@ -0,0 +1,306 @@ +using Amazon.DynamoDBv2.DataModel; +using FluentAssertions; +using InstarBot.Tests.Models; +using InstarBot.Tests.Services; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Services; +using Xunit; +using Metric = PaxAndromeda.Instar.Metrics.Metric; + +namespace InstarBot.Tests.Integration.Services; + +public static class BirthdaySystemTests +{ + private static async Task Setup(DateTime todayLocal, DateTimeOffset? birthdate = null, bool applyBirthday = false, Func, InstarDynamicConfiguration, List>? roleUpdateFn = null) + { + var today = todayLocal.ToUniversalTime(); + var timeProviderMock = new Mock(); + timeProviderMock.Setup(n => n.GetUtcNow()).Returns(today); + + Birthday? birthday = birthdate is null ? null : new Birthday(birthdate.Value, timeProviderMock.Object); + + var testUserId = Snowflake.Generate(); + var cfg = TestUtilities.GetDynamicConfiguration(); + var mockDDB = new MockInstarDDBService(); + var metrics = new MockMetricService(); + var polledCfg = await cfg.GetConfig(); + + var discord = TestUtilities.SetupDiscordService(new TestContext + { + UserID = Snowflake.Generate(), + Channels = + { + { polledCfg.BirthdayConfig.BirthdayAnnounceChannel, new TestChannel(polledCfg.BirthdayConfig.BirthdayAnnounceChannel) } + } + }) as MockDiscordService; + + discord.Should().NotBeNull(); + + var user = new TestGuildUser + { + Id = testUserId, + Username = "TestUser" + }; + + var rolesToAdd = new List(); // Some random role + + if (applyBirthday) + rolesToAdd.Add(polledCfg.BirthdayConfig.BirthdayRole); + + if (birthday is not null) + { + int priorYearsOld = birthday.Age - 1; + var ageRole = polledCfg.BirthdayConfig.AgeRoleMap + .OrderByDescending(n => n.Age) + .SkipWhile(n => n.Age > priorYearsOld) + .First(); + + rolesToAdd.Add(ageRole.Role.ID); + } + + if (roleUpdateFn is not null) + rolesToAdd = roleUpdateFn(rolesToAdd, polledCfg); + + await user.AddRolesAsync(rolesToAdd); + + + discord.AddUser(user); + + var dbUser = InstarUserData.CreateFrom(user); + + if (birthday is not null) + { + dbUser.Birthday = birthday; + dbUser.Birthdate = birthday.Key; + + if (birthday.IsToday) + { + mockDDB.Setup(n => n.GetUsersByBirthday(today, It.IsAny())) + .ReturnsAsync([new InstarDatabaseEntry(Mock.Of(), dbUser)]); + } + } + + await mockDDB.CreateUserAsync(dbUser); + + + + var birthdaySystem = new BirthdaySystem(cfg, discord, mockDDB, metrics, timeProviderMock.Object); + + return new Context(testUserId, birthdaySystem, mockDDB, discord, metrics, polledCfg, birthday); + } + + private static bool IsDateMatch(DateTime a, DateTime b) + { + // Match everything but year + var aUtc = a.ToUniversalTime(); + var bUtc = b.ToUniversalTime(); + + return aUtc.Month == bUtc.Month && aUtc.Day == bUtc.Day && aUtc.Hour == bUtc.Hour; + } + + [Fact] + public static async Task BirthdaySystem_WhenUserBirthday_ShouldGrantRole() + { + // Arrange + var birthday = DateTime.Parse("2000-02-14T00:00:00Z"); + var today = DateTime.Parse("2025-02-14T00:00:00Z"); + + var ctx = await Setup(today, birthday); + + // Act + await ctx.System.RunAsync(); + + // Assert + // We expect a few things here: the test user should now have the birthday + // role, and there should now be a message in the birthday announce channel. + + var user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.AgeRoleMap.MaxBy(n => n.Age)!.Role.ID); + + var channel = await ctx.Discord.GetChannel(ctx.Cfg.BirthdayConfig.BirthdayAnnounceChannel) as TestChannel; + channel.Should().NotBeNull(); + + var messages = await channel.GetMessagesAsync().SelectMany(n => n).ToListAsync(); + messages.Count.Should().BeGreaterThan(0); + TestUtilities.MatchesFormat(Strings.Birthday_Announcement, messages[0].Content); + + ctx.Metrics.GetMetricValues(Metric.BirthdaySystem_Failures).Sum().Should().Be(0); + ctx.Metrics.GetMetricValues(Metric.BirthdaySystem_Grants).Sum().Should().Be(1); + } + + [Fact] + public static async Task BirthdaySystem_WhenUserBirthday_ShouldUpdateAgeRoles() + { + // Arrange + var birthdate = DateTime.Parse("2000-02-14T00:00:00Z"); + var today = DateTime.Parse("2016-02-14T00:00:00Z"); + + var ctx = await Setup(today, birthdate); + + int yearsOld = ctx.Birthday.Age; + + var priorAgeSnowflake = ctx.Cfg.BirthdayConfig.AgeRoleMap.First(n => n.Age == yearsOld - 1).Role; + var newAgeSnowflake = ctx.Cfg.BirthdayConfig.AgeRoleMap.First(n => n.Age == yearsOld).Role; + + // Preassert + var user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().Contain(priorAgeSnowflake); + user.RoleIds.Should().NotContain(newAgeSnowflake); + + + // Act + await ctx.System.RunAsync(); + + // Assert + // The main thing we're looking for in this test is whether the previous age + // role was removed and the new one applied. + user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().NotContain(priorAgeSnowflake); + user.RoleIds.Should().Contain(newAgeSnowflake); + } + + [Fact] + public static async Task BirthdaySystem_WhenUserBirthdayWithNoYear_ShouldNotUpdateAgeRoles() + { + // Arrange + var birthday = DateTime.Parse("1600-02-14T00:00:00Z"); + var today = DateTime.Parse("2016-02-14T00:00:00Z"); + + var ctx = await Setup(today, birthday, roleUpdateFn: (_, cfg) => + { + // just return the 16 age role + return [ cfg.BirthdayConfig.AgeRoleMap.First(n => n.Age == 16).Role.ID ]; + }); + + // Preassert + var user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + + user.RoleIds.Should().ContainSingle(); + var priorRoleId = user.RoleIds.First(); + + // Act + await ctx.System.RunAsync(); + + // Assert + user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + // Since the user's birth year isn't set, we can't actually calculate their age, + // so we expect that their age roles won't be changed. + user.RoleIds.Should().HaveCount(2); + user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + user.RoleIds.Should().Contain(priorRoleId); + } + + [Fact] + public static async Task BirthdaySystem_WhenNoBirthdays_ShouldDoNothing() + { + // Arrange + var birthday = DateTime.Parse("2000-02-14T00:00:00Z"); + var today = DateTime.Parse("2025-02-17T00:00:00Z"); + + var ctx = await Setup(today, birthday); + + // Act + await ctx.System.RunAsync(); + + // Assert + var user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().NotContain(ctx.Cfg.BirthdayConfig.BirthdayRole); + + var channel = await ctx.Discord.GetChannel(ctx.Cfg.BirthdayConfig.BirthdayAnnounceChannel) as TestChannel; + channel.Should().NotBeNull(); + + var messages = await channel.GetMessagesAsync().SelectMany(n => n).ToListAsync(); + messages.Count.Should().Be(0); + } + + [Fact] + public static async Task BirthdaySystem_WithUserHavingOldBirthday_ShouldRemoveOldBirthdayRoles() + { + // Arrange + var birthday = DateTime.Parse("2000-02-13T00:00:00Z"); + var today = DateTime.Parse("2025-02-14T00:00:00Z"); + + var ctx = await Setup(today, birthday, true); + + // Pre assert + var user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + + + // Act + await ctx.System.RunAsync(); + + // Assert + user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().NotContain(ctx.Cfg.BirthdayConfig.BirthdayRole); + } + + [Fact] + public static async Task BirthdaySystem_WithBirthdayRoleButNoBirthdayRecord_ShouldRemoveBirthdayRoles() + { + // Arrange + var birthday = DateTime.Parse("2000-02-13T00:00:00Z"); + var today = DateTime.Parse("2025-02-14T00:00:00Z"); + + var ctx = await Setup(today, applyBirthday: true); + + // Pre assert + var user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + + + // Act + await ctx.System.RunAsync(); + + // Assert + user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().NotContain(ctx.Cfg.BirthdayConfig.BirthdayRole); + } + + [Fact] + public static async Task BirthdaySystem_WithUserBirthdayStill_ShouldKeepBirthdayRoles() + { + // Arrange + var birthday = DateTime.Parse("2000-02-13T12:00:00Z"); + var today = DateTime.Parse("2025-02-14T00:00:00Z"); + + var ctx = await Setup(today, birthday, true); + + // Pre assert + var user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + + // Act + await ctx.System.RunAsync(); + + // Assert + user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + } + + private record Context( + Snowflake TestUserId, + BirthdaySystem System, + MockInstarDDBService DDB, + MockDiscordService Discord, + MockMetricService Metrics, + InstarDynamicConfiguration Cfg, + Birthday? Birthday + ); +} \ No newline at end of file diff --git a/InstarBot.Tests.Unit/AsyncAutoResetEventTests.cs b/InstarBot.Tests.Unit/AsyncAutoResetEventTests.cs new file mode 100644 index 0000000..de0ac0c --- /dev/null +++ b/InstarBot.Tests.Unit/AsyncAutoResetEventTests.cs @@ -0,0 +1,74 @@ +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using PaxAndromeda.Instar; +using Xunit; + +namespace InstarBot.Tests; + +public class AsyncAutoResetEventTests +{ + [Fact] + public void WaitAsync_CompletesImmediately_WhenInitiallySignaled() + { + // Arrange + var ev = new AsyncAutoResetEvent(true); + + // Act + var task = ev.WaitAsync(); + + // Assert + task.IsCompleted.Should().BeTrue(); + } + + [Fact] + public void Set_ReleasesSingleWaiter() + { + var ev = new AsyncAutoResetEvent(false); + + var waiter1 = ev.WaitAsync(); + var waiter2 = ev.WaitAsync(); + + // First Set should release only one waiter. + ev.Set(); + + // Ensure waiter1 has completed, but waiter2 is not + waiter1.IsCompleted.Should().BeTrue(); + waiter2.IsCompleted.Should().BeFalse(); + + // Second Set should release the remaining waiter. + ev.Set(); + waiter2.IsCompleted.Should().BeTrue(); + } + + [Fact] + public void Set_MarksEventSignaled_WhenNoWaiters() + { + var ev = new AsyncAutoResetEvent(false); + + // No waiters now — Set should mark the event signaled so the next WaitAsync completes immediately. + ev.Set(); + + var immediate = ev.WaitAsync(); + + // WaitAsync should complete immediately after Set() when there were no waiters + immediate.IsCompleted.Should().BeTrue(); + + // That consumption should reset the event; a subsequent waiter should block. + var next = ev.WaitAsync(); + + next.IsCompleted.Should().BeFalse(); + } + + [Fact] + public async Task WaitAsync_Cancels_WhenCancellationRequested() + { + var ev = new AsyncAutoResetEvent(false); + using var cts = new CancellationTokenSource(); + + var task = ev.WaitAsync(cts.Token); + await cts.CancelAsync(); + + await Assert.ThrowsAsync(async () => await task); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Unit/BirthdayTests.cs b/InstarBot.Tests.Unit/BirthdayTests.cs new file mode 100644 index 0000000..41303e4 --- /dev/null +++ b/InstarBot.Tests.Unit/BirthdayTests.cs @@ -0,0 +1,94 @@ +using System; +using FluentAssertions; +using Moq; +using PaxAndromeda.Instar; +using Xunit; + +namespace InstarBot.Tests; + +public class BirthdayTests +{ + private TimeProvider GetTimeProvider(DateTimeOffset dateTime) + { + var mock = new Mock(); + mock.Setup(n => n.GetUtcNow()).Returns(dateTime.UtcDateTime); + + return mock.Object; + } + + [Theory] + [InlineData("2025-08-01T00:00:00Z", "1992-07-01T00:00:00Z", 33)] // after birthday + [InlineData("2025-07-01T00:00:00Z", "1992-07-01T00:00:00Z", 33)] // on birthday + [InlineData("2025-06-01T00:00:00Z", "1992-07-01T00:00:00Z", 32)] // before birthday + [InlineData("2025-02-14T00:00:00Z", "1992-02-29T00:00:00Z", 32)] // leap year Birthdate before birthday + [InlineData("2025-03-01T00:00:00Z", "1992-02-29T00:00:00Z", 33)] // leap year Birthdate after birthday + public void Age_ShouldReturnExpectedAge(string currentDateStr, string birthDateStr, int expectedAge) + { + var timeProvider = GetTimeProvider(DateTime.Parse(currentDateStr)); + var birthDate = DateTime.Parse(birthDateStr).ToUniversalTime(); + + var birthday = new Birthday(birthDate, timeProvider); + + birthday.Age.Should().Be(expectedAge); + } + + [Theory] + [InlineData("2025-08-01T12:00:00-07:00", "2025-08-01T13:00:00-07:00", true)] + [InlineData("2025-08-01T12:00:00-07:00", "2025-08-02T13:00:00-07:00", false)] + [InlineData("2025-08-01T12:00:00Z", "2025-08-01T12:00:00-07:00", true)] + [InlineData("2025-02-28T12:00:00Z", "2024-02-29T12:00:00Z", true)] + public void IsToday_ShouldReturnExpected(string currentUtc, string testTime, bool expected) + { + var timeProvider = GetTimeProvider(DateTime.Parse(currentUtc)); + var birthDate = DateTime.Parse(testTime).ToUniversalTime(); + + var birthday = new Birthday(birthDate, timeProvider); + + birthday.IsToday.Should().Be(expected); + } + + [Theory] + [InlineData("2000-07-01T00:00:00Z", "07010000")] + [InlineData("2001-07-01T00:00:00Z", "07010000")] + [InlineData("2000-07-01T00:00:00-08:00", "07010800")] + [InlineData("2000-08-01T00:00:00Z", "08010000")] + [InlineData("2000-02-29T00:00:00Z", "02290000")] + public void Key_ShouldReturnExpectedDatabaseKey(string date, string expectedKey) + { + var timeProvider = GetTimeProvider(DateTime.Parse(date)); + var birthDate = DateTime.Parse(date).ToUniversalTime(); + + var birthday = new Birthday(birthDate, timeProvider); + + birthday.Key.Should().Be(expectedKey); + } + + [Theory] + [InlineData("2000-07-01T00:00:00Z", 962409600)] // normal date + [InlineData("1970-01-01T00:00:00Z", 0)] // epoch + [InlineData("1969-12-31T23:59:59Z", -1)] // just before epoch + [InlineData("1900-01-01T00:00:00Z", -2208988800)] // far before epoch + public void Timestamp_ShouldReturnExpectedTimestamp(string date, long expectedTimestamp) + { + var birthDate = DateTime.Parse(date).ToUniversalTime(); + + var birthday = new Birthday(birthDate, TimeProvider.System); + + birthday.Timestamp.Should().Be(expectedTimestamp); + } + + [Theory] + [InlineData("2025-06-15T00:00:00Z", "1990-07-01T00:00:00Z", "2025-07-01T00:00:00Z")] // birthday later in year + [InlineData("2025-08-15T00:00:00Z", "1990-07-01T00:00:00Z", "2025-07-01T00:00:00Z")] // birthday earlier in year + [InlineData("2024-02-15T00:00:00Z", "1992-02-29T00:00:00Z", "2024-02-29T00:00:00Z")] // leap year birthday on leap year + [InlineData("2025-02-15T00:00:00Z", "1992-02-29T00:00:00Z", "2025-02-28T00:00:00Z")] // leap year birthday on non-leap year + public void Observed_ShouldReturnThisYearsBirthday(string currentTime, string birthdate, string expectedObservedDate) + { + var timeProvider = GetTimeProvider(DateTime.Parse(currentTime)); + var birthday = new Birthday(DateTimeOffset.Parse(birthdate), timeProvider); + + var expectedTime = DateTimeOffset.Parse(expectedObservedDate); + + birthday.Observed.Should().Be(expectedTime); + } +} \ No newline at end of file diff --git a/InstarBot/AsyncAutoResetEvent.cs b/InstarBot/AsyncAutoResetEvent.cs new file mode 100644 index 0000000..ce92c56 --- /dev/null +++ b/InstarBot/AsyncAutoResetEvent.cs @@ -0,0 +1,82 @@ +namespace PaxAndromeda.Instar; + +/// +/// Provides an asynchronous auto-reset event that allows tasks to wait for a signal and ensures +/// that only one waiting task is released per signal. +/// +/// +/// AsyncAutoResetEvent enables coordination between asynchronous operations by allowing tasks to wait +/// until the event is signaled. When Set is called, only one waiting task is released; subsequent calls +/// to WaitAsync will wait until the event is signaled again. This class is thread-safe and can be used +/// in scenarios where asynchronous signaling is required, such as implementing producer-consumer +/// patterns or throttling access to resources. +/// +/// +/// True to initialize the event in the signaled state so that the first call to WaitAsync completes +/// immediately; otherwise, false to initialize in the non-signaled state. +/// +public sealed class AsyncAutoResetEvent(bool signaled) +{ + private readonly Queue _queue = new(); + + private bool _signaled = signaled; + + /// + /// Asynchronously waits until the signal is set or the operation is canceled. + /// + /// If the signal is already set when this method is called, the returned task completes immediately. + /// Multiple callers may wait concurrently; only one will be released per signal. This method is thread-safe. + /// A cancellation token that can be used to cancel the wait operation before the signal is set. If cancellation is + /// requested, the returned task will be canceled. + /// A task that completes when the signal is set or is canceled if the provided cancellation token is triggered. + public Task WaitAsync(CancellationToken cancellationToken = default) + { + lock (_queue) + { + if (_signaled) + { + _signaled = false; + return Task.CompletedTask; + } + else + { + var tcs = new TaskCompletionSource(); + if (cancellationToken.CanBeCanceled) + { + // If the token is cancelled, cancel the waiter. + var registration = cancellationToken.Register(() => tcs.TrySetCanceled(), useSynchronizationContext: false); + + // If the waiter completes or faults, unregister our interest in cancellation. + tcs.Task.ContinueWith( + _ => registration.Unregister(), + cancellationToken, + TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuationOptions.NotOnFaulted, + TaskScheduler.Default); + } + _queue.Enqueue(tcs); + return tcs.Task; + } + } + } + + /// + /// Signals the event, allowing one waiting operation to proceed or marking the event as signaled if no operations are + /// waiting. + /// + /// If there are pending operations waiting for the event, one will be released. If no operations are + /// waiting, the event remains signaled until a future wait occurs. This method is thread-safe and can be called from + /// multiple threads. + public void Set() + { + TaskCompletionSource? toRelease = null; + + lock (_queue) + if (_queue.Count > 0) + toRelease = _queue.Dequeue(); + else if (!_signaled) + _signaled = true; + + // It's possible that the TCS has already been cancelled. + toRelease?.TrySetResult(); + } +} \ No newline at end of file diff --git a/InstarBot/AsyncEvent.cs b/InstarBot/AsyncEvent.cs index 5c6bc33..4b65bbe 100644 --- a/InstarBot/AsyncEvent.cs +++ b/InstarBot/AsyncEvent.cs @@ -24,14 +24,14 @@ public async Task Invoke(T parameter) public void Add(Func subscriber) { - Guard.Against.Null(subscriber, nameof(subscriber)); + Guard.Against.Null(subscriber); lock (_subLock) _subscriptions = _subscriptions.Add(subscriber); } public void Remove(Func subscriber) { - Guard.Against.Null(subscriber, nameof(subscriber)); + Guard.Against.Null(subscriber); lock (_subLock) _subscriptions = _subscriptions.Remove(subscriber); } diff --git a/InstarBot/Birthday.cs b/InstarBot/Birthday.cs new file mode 100644 index 0000000..5df8cf5 --- /dev/null +++ b/InstarBot/Birthday.cs @@ -0,0 +1,118 @@ +using System.Diagnostics.CodeAnalysis; + +namespace PaxAndromeda.Instar; + +/// +/// Represents a person's birthday, providing methods to calculate the observed birthday, age, and related information +/// based on a specified date and time provider. +/// +/// The date and time of birth, including the time zone offset. +/// An object that supplies the current date and time. If not specified, the system time provider is used. +public record Birthday(DateTimeOffset Birthdate, TimeProvider TimeProvider) +{ + /// + /// Gets the date of the next observed birthday based on the current year. + /// + /// + /// For most scenarios, this is the date in the current year with the same month and day + /// as the user's Birthdate. However, for users born on February 29th, this will be defined + /// as the day immediately preceding March 1st in non-leap years (i.e., February 28th). + /// + public DateTimeOffset Observed + { + get + { + var birthdayNormalized = Normalize(Birthdate); + var now = TimeProvider.GetUtcNow().ToOffset(birthdayNormalized.Offset); + + return new DateTimeOffset(now.Year, birthdayNormalized.Month, birthdayNormalized.Day, + birthdayNormalized.Hour, birthdayNormalized.Minute, birthdayNormalized.Second, + birthdayNormalized.Offset); + } + } + + /// + /// Gets the age, in years, calculated from the Birthdate to the current date. + /// + /// + /// To accurately determine the age of users born on February 29th, this calculation + /// will use the normalized Birthdate for the given year, which will be defined as + /// the day immediately preceding March 1st in non-leap years (i.e., February 28th). + /// + public int Age + { + get + { + var now = TimeProvider.GetUtcNow().ToOffset(Birthdate.Offset); + + // Preliminary age based on year difference + int age = now.Year - Birthdate.Year; + + // If the test date is before the anniversary this year, they haven't had their birthday yet + if (now < Observed) + age--; + + return age; + } + } + + /// + /// Gets a value indicating whether the observed date and time occur on the current day in the observed time zone. + /// + /// This property compares the date portion of the observed time with the current date, adjusted to the + /// same time zone offset as the observed value. It returns if the observed time falls within + /// the range of the current local day; otherwise, . + public bool IsToday + { + get + { + var dtNow = TimeProvider.GetUtcNow(); + + // Convert current UTC time to the same offset as localTime + var utcOffset = Observed.Offset; + var currentLocalTime = dtNow.ToOffset(utcOffset); + + var localTimeToday = new DateTimeOffset(currentLocalTime.Date, currentLocalTime.Offset); + var localTimeTomorrow = localTimeToday.Date.AddDays(1); + + return Observed >= localTimeToday && Observed < localTimeTomorrow; + } + } + + /// + /// Returns a string key representing the specified Birthdate in UTC, formatted as month, day, hour, and minute. + /// + /// This method is useful for creating compact, sortable keys based on Birthdate and time. The returned + /// string uses UTC time to ensure consistency across time zones. + public string Key => Utilities.ToBirthdateKey(Birthdate); + + /// + /// Gets the Unix timestamp representing the number of seconds that have elapsed since 00:00:00 UTC on 1 January 1970 + /// (the Unix epoch) for the associated Birthdate. + /// + public long Timestamp => Birthdate.ToUnixTimeSeconds(); + + [ExcludeFromCodeCoverage] + public Birthday(int year, int month, int day, int hour, int minute, int second, DateTimeKind kind, TimeProvider provider) + : this(new DateTimeOffset(new DateTime(year, month, day, hour, minute, second, kind)), provider) + { } + + [ExcludeFromCodeCoverage] + public Birthday(int year, int month, int day, int hour, int minute, int second, TimeSpan offset, TimeProvider provider) + : this(new DateTimeOffset(year, month, day, hour, minute, second, offset), provider) + { } + + private DateTimeOffset Normalize(DateTimeOffset dto) + { + // Return as is if the year is a leap year + if (DateTime.IsLeapYear(TimeProvider.GetUtcNow().ToOffset(Birthdate.Offset).Year)) + return dto; + + return dto is not { Month: 2, Day: 29 } + // Return as is if the date is not Feb 29 + ? dto + + // Default to Feb 28 on non-leap years. Sorry Feb 29 babies. + : new DateTimeOffset(dto.Year, 2, 28, dto.Hour, dto.Minute, dto.Second, dto.Offset); + } +} \ No newline at end of file diff --git a/InstarBot/Commands/AutoMemberHoldCommand.cs b/InstarBot/Commands/AutoMemberHoldCommand.cs index daa858f..7bc606d 100644 --- a/InstarBot/Commands/AutoMemberHoldCommand.cs +++ b/InstarBot/Commands/AutoMemberHoldCommand.cs @@ -11,7 +11,7 @@ namespace PaxAndromeda.Instar.Commands; [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class AutoMemberHoldCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfigService, IMetricService metricService) : BaseCommand +public class AutoMemberHoldCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfigService, IMetricService metricService, TimeProvider timeProvider) : BaseCommand { [UsedImplicitly] [SlashCommand("amh", "Withhold automatic membership grants to a user.")] @@ -26,7 +26,7 @@ string reason Guard.Against.Null(Context.User); Guard.Against.Null(user); - var date = DateTime.UtcNow; + var date = timeProvider.GetUtcNow().UtcDateTime; Snowflake modId = Context.User.Id; try diff --git a/InstarBot/Commands/ResetBirthdayCommand.cs b/InstarBot/Commands/ResetBirthdayCommand.cs new file mode 100644 index 0000000..44914d6 --- /dev/null +++ b/InstarBot/Commands/ResetBirthdayCommand.cs @@ -0,0 +1,74 @@ +using Discord; +using Discord.Interactions; +using JetBrains.Annotations; +using PaxAndromeda.Instar.Preconditions; +using PaxAndromeda.Instar.Services; +using Serilog; + +namespace PaxAndromeda.Instar.Commands; + +public class ResetBirthdayCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfig) : BaseCommand +{ + /* + * Concern: This command runs very slowly, and might end up hitting the 3-second limit from Discord. + * If we start seeing timeouts, we may need to make the end user notification asynchronous. + */ + [UsedImplicitly] + [SlashCommand("resetbirthday", "Resets a user's birthday, allowing them to set it again.")] + [RequireStaffMember] + public async Task ResetBirthday( + [Summary("user", "The user to reset the birthday of.")] + IUser user + ) + { + try + { + var dbUser = await ddbService.GetUserAsync(user.Id); + + if (dbUser is null) + { + await RespondAsync(string.Format(Strings.Command_ResetBirthday_Error_UserNotFound, user.Id), ephemeral: true); + return; + } + + dbUser.Data.Birthday = null; + dbUser.Data.Birthdate = null; + await dbUser.UpdateAsync(); + + if (user is IGuildUser guildUser) + { + var cfg = await dynamicConfig.GetConfig(); + + if (guildUser.RoleIds.Contains(cfg.BirthdayConfig.BirthdayRole)) + { + try + { + await guildUser.RemoveRoleAsync(cfg.BirthdayConfig.BirthdayRole); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to remove birthday role from user {UserID}", user.Id); + + await RespondAsync(string.Format(Strings.Command_ResetBirthday_Error_RemoveBirthdayRole, user.Id)); + return; + } + } + + try + { + await guildUser.SendMessageAsync(Strings.Command_ResetBirthday_EndUserNotification); + } catch (Exception dmEx) + { + Log.Error(dmEx, "Failed to send a DM to user {UserID}", user.Id); + // ignore + } + } + + await RespondAsync(string.Format(Strings.Command_ResetBirthday_Success, user.Id), ephemeral: true); + } catch (Exception ex) + { + Log.Error(ex, "Failed to reset the birthday of {UserID}", user.Id); + await RespondAsync(string.Format(Strings.Command_ResetBirthday_Error_Unknown, user.Id), ephemeral: true); + } + } +} \ No newline at end of file diff --git a/InstarBot/Commands/SetBirthdayCommand.cs b/InstarBot/Commands/SetBirthdayCommand.cs index 677e619..af2d894 100644 --- a/InstarBot/Commands/SetBirthdayCommand.cs +++ b/InstarBot/Commands/SetBirthdayCommand.cs @@ -1,95 +1,143 @@ -using System.Diagnostics.CodeAnalysis; -using Discord; +using Discord; using Discord.Interactions; using Discord.WebSocket; using JetBrains.Annotations; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Embeds; using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Serilog; +using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Commands; // Required to be unsealed for mocking [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class SetBirthdayCommand(IInstarDDBService ddbService, IMetricService metricService) : BaseCommand +public class SetBirthdayCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfig, IMetricService metricService, IBirthdaySystem birthdaySystem, TimeProvider timeProvider) : BaseCommand { - [UsedImplicitly] + /// + /// The default year to use when none is provided. We select a year that is sufficiently + /// far in the past to be obviously unset, as well as a leap year to accommodate February 29th birthdays. + /// + /// DISCLAIMER: We are not actually asserting that a user that does not provide a year is 425 years old. + /// + private const int DefaultYear = 1600; + + [UsedImplicitly] [SlashCommand("setbirthday", "Sets your birthday on the server.")] - public async Task SetBirthday( - [MinValue(1)] [MaxValue(12)] [Summary(description: "The month you were born.")] - Month month, - [MinValue(1)] [MaxValue(31)] [Summary(description: "The day you were born.")] - int day, - [MinValue(1900)] [MaxValue(2099)] [Summary(description: "The year you were born.")] - int year, - [MinValue(-12)] - [MaxValue(+12)] - [Summary("timezone", "Select your nearest time zone offset in hours from GMT.")] - [Autocomplete] - int tzOffset = 0) - { - if (Context.User is null) - { - Log.Warning("Context.User was null"); - await RespondAsync( - "An unknown error has occurred. Instar developers have been notified.", - ephemeral: true); - return; - } - - if ((int)month is < 0 or > 12) - { - await RespondAsync( - "There are only 12 months in a year. Your birthday was not set.", - ephemeral: true); - return; - } + public async Task SetBirthday( + [MinValue(1)] [MaxValue(12)] [Summary(description: "The month you were born.")] + Month month, + [MinValue(1)] [MaxValue(31)] [Summary(description: "The day you were born.")] + int day, + [MinValue(1900)] [MaxValue(2099)] [Summary(description: "The year you were born. We use this to automatically update age roles.")] + int? year = null, + [MinValue(-12)] + [MaxValue(+12)] + [Summary("timezone", "Set your time zone so your birthday role is applied at the correct time of day.")] + [Autocomplete] + int tzOffset = 0) + { + if (Context.User is null) + { + Log.Warning("Context.User was null"); + await RespondAsync( + Strings.Command_SetBirthday_Error_Unknown, + ephemeral: true); + return; + } - var daysInMonth = DateTime.DaysInMonth(year, (int)month); + if ((int) month is < 0 or > 12) + { + await RespondAsync( + Strings.Command_SetBirthday_MonthsOutOfRange, + ephemeral: true); + return; + } - // First step: Does the provided number of days exceed the number of days in the given month? - if (day > daysInMonth) - { - await RespondAsync( - $"There are only {daysInMonth} days in {month} {year}. Your birthday was not set.", - ephemeral: true); - return; - } + // We have to assume a leap year if the user did not provide a year. + int actualYear = year ?? DefaultYear; - var unspecifiedDate = new DateTime(year, (int)month, day, 0, 0, 0, DateTimeKind.Unspecified); - var dtZ = new DateTimeOffset(unspecifiedDate, TimeSpan.FromHours(tzOffset)); + var daysInMonth = DateTime.DaysInMonth(actualYear, (int)month); - var dtLocal = dtZ.DateTime; - var dtUtc = dtZ.UtcDateTime; + // First step: Does the provided number of days exceed the number of days in the given month? + if (day > daysInMonth) + { + await RespondAsync( + string.Format(Strings.Command_SetBirthday_DaysInMonthOutOfRange, daysInMonth, month, year), + ephemeral: true); + return; + } - // Second step: Is the provided birthday actually in the future? - if (dtUtc > DateTime.UtcNow) - { - await RespondAsync( - "You are not a time traveler. Your birthday was not set.", - ephemeral: true); - return; - } + var unspecifiedDate = new DateTime(actualYear, (int)month, day, 0, 0, 0, DateTimeKind.Unspecified); + var dtZ = new DateTimeOffset(unspecifiedDate, TimeSpan.FromHours(tzOffset)); + + // Second step: Is the provided birthday actually in the future? + if (dtZ.UtcDateTime > timeProvider.GetUtcNow()) + { + await RespondAsync( + Strings.Command_SetBirthday_NotTimeTraveler, + ephemeral: true); + return; + } + + var birthday = new Birthday(dtZ, timeProvider); - // Third step: Is the user below the age of 13? - // Note: We will assume all years are 365.25 days to account for leap year madness. - if (DateTime.UtcNow - dtUtc < TimeSpan.FromDays(365.25 * 13)) - Log.Warning("User {UserID} recorded a birthday that puts their age below 13! {UtcTime}", Context.User!.Id, - dtUtc); - // TODO: Notify staff? + + + // Third step: Is the user below the age of 13? + var cfg = await dynamicConfig.GetConfig(); + bool isUnderage = birthday.Age < cfg.BirthdayConfig.MinimumPermissibleAge; try { var dbUser = await ddbService.GetOrCreateUserAsync(Context.User); - dbUser.Data.Birthday = dtUtc; - await dbUser.UpdateAsync(); + if (dbUser.Data.Birthday is not null) + { + var originalBirthdayTimestamp = dbUser.Data.Birthday.Timestamp; + await RespondAsync(string.Format(Strings.Command_SetBirthday_Error_AlreadySet, originalBirthdayTimestamp), ephemeral: true); + return; + } + + if (isUnderage) + { + Log.Warning("User {UserID} recorded a birthday that puts their age below 13! {UtcTime}", Context.User!.Id, + birthday.Birthdate.UtcDateTime); + + await HandleUnderage(cfg, Context.User, birthday); + } + dbUser.Data.Birthday = birthday; + dbUser.Data.Birthdate = birthday.Key; + // If the user is underage and is a new member and does not already have an auto member hold record, + // we automatically withhold their membership for staff review. + if (isUnderage && dbUser.Data is { Position: InstarUserPosition.NewMember, AutoMemberHoldRecord: null }) + { + dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + Date = timeProvider.GetUtcNow().UtcDateTime, + ModeratorID = cfg.BotUserID, + Reason = string.Format(Strings.Command_SetBirthday_Underage_AMHReason, birthday.Timestamp, cfg.BirthdayConfig.MinimumPermissibleAge) + }; + } + + await dbUser.UpdateAsync(); + Log.Information("User {UserID} birthday set to {DateTime} (UTC time calculated as {UtcTime})", Context.User!.Id, - dtLocal, dtUtc); + birthday.Birthdate, birthday.Birthdate.UtcDateTime); + + // Fourth step: Grant birthday role if the user's birthday is today THEIR time. + // TODO: Ensure that a user is granted/removed birthday roles appropriately after setting their birthday IF their birthday is today. + if (birthday.IsToday) + { + // User's birthday is today in their timezone; grant birthday role. + await birthdaySystem.GrantUnexpectedBirthday(Context.User, birthday); + } - await RespondAsync($"Your birthday was set to {dtLocal:D}.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_SetBirthday_Success, birthday.Timestamp), ephemeral: true); await metricService.Emit(Metric.BS_BirthdaysSet, 1); } catch (Exception ex) @@ -97,12 +145,21 @@ await RespondAsync( Log.Error(ex, "Failed to update {UserID}'s birthday due to a DynamoDB failure", Context.User!.Id); - await RespondAsync("Your birthday could not be set at this time. Please try again later.", + await RespondAsync(Strings.Command_SetBirthday_Error_CouldNotSetBirthday, ephemeral: true); } } - [UsedImplicitly] + private async Task HandleUnderage(InstarDynamicConfiguration cfg, IGuildUser user, Birthday birthday) + { + var staffAnnounceChannel = Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel); + + var warningEmbed = new InstarUnderageUserWarningEmbed(cfg, user, user.RoleIds.Contains(cfg.MemberRoleID), birthday).Build(); + + await staffAnnounceChannel.SendMessageAsync($"<@&{cfg.StaffRoleID}>", embed: warningEmbed); + } + + [UsedImplicitly] [ExcludeFromCodeCoverage(Justification = "No logic. Just returns list")] [AutocompleteCommand("timezone", "setbirthday")] public async Task HandleTimezoneAutocomplete() @@ -113,7 +170,7 @@ public async Task HandleTimezoneAutocomplete() new("GMT-12 International Date Line West", -12), new("GMT-11 Midway Island, Samoa", -11), new("GMT-10 Hawaii", -10), - new("GMT-9 Alaska", -9), + new("GMT-9 Alaska, R'lyeh", -9), new("GMT-8 Pacific Time (US and Canada); Tijuana", -8), new("GMT-7 Mountain Time (US and Canada)", -7), new("GMT-6 Central Time (US and Canada)", -6), diff --git a/InstarBot/Commands/TriggerBirthdaySystemCommand.cs b/InstarBot/Commands/TriggerBirthdaySystemCommand.cs new file mode 100644 index 0000000..28107f6 --- /dev/null +++ b/InstarBot/Commands/TriggerBirthdaySystemCommand.cs @@ -0,0 +1,21 @@ +using Discord; +using Discord.Interactions; +using JetBrains.Annotations; +using PaxAndromeda.Instar.Services; + +namespace PaxAndromeda.Instar.Commands; + +public class TriggerBirthdaySystemCommand(IBirthdaySystem birthdaySystem) : BaseCommand +{ + [UsedImplicitly] + [RequireOwner] + [DefaultMemberPermissions(GuildPermission.Administrator)] + [SlashCommand("runbirthdays", "Manually triggers an auto member system run.")] + public async Task RunBirthdays() + { + await RespondAsync("Auto Member System is running!", ephemeral: true); + + // Run it asynchronously + await birthdaySystem.RunAsync(); + } +} \ No newline at end of file diff --git a/InstarBot/ConfigModels/InstarDynamicConfiguration.cs b/InstarBot/ConfigModels/InstarDynamicConfiguration.cs index 3631454..62a35a0 100644 --- a/InstarBot/ConfigModels/InstarDynamicConfiguration.cs +++ b/InstarBot/ConfigModels/InstarDynamicConfiguration.cs @@ -8,7 +8,8 @@ namespace PaxAndromeda.Instar.ConfigModels; public sealed class InstarDynamicConfiguration { public string BotName { get; set; } = null!; - [SnowflakeType(SnowflakeType.Guild)] public Snowflake TargetGuild { get; set; } = null!; + [SnowflakeType(SnowflakeType.User)] public Snowflake BotUserID { get; set; } = null!; + [SnowflakeType(SnowflakeType.Guild)] public Snowflake TargetGuild { get; set; } = null!; [SnowflakeType(SnowflakeType.Channel)] public Snowflake TargetChannel { get; set; } = null!; public string Token { get; set; } = null!; public string GaiusAPIKey { get; set; } = null!; @@ -19,10 +20,31 @@ public sealed class InstarDynamicConfiguration [SnowflakeType(SnowflakeType.Role)] public Snowflake MemberRoleID { get; set; } = null!; public Snowflake[] AuthorizedStaffID { get; set; } = null!; public AutoMemberConfig AutoMemberConfig { get; set; } = null!; + public BirthdayConfig BirthdayConfig { get; set; } = null!; public Team[] Teams { get; set; } = null!; public Dictionary FunCommands { get; set; } = null!; } +[UsedImplicitly] +public class BirthdayConfig +{ + [SnowflakeType(SnowflakeType.Role)] + public Snowflake BirthdayRole { get; set; } = null!; + [SnowflakeType(SnowflakeType.Channel)] + public Snowflake BirthdayAnnounceChannel { get; set; } = null!; + public int MinimumPermissibleAge { get; set; } + public List AgeRoleMap { get; set; } = null!; +} + +[UsedImplicitly] +public record AgeRoleMapping +{ + public int Age { get; set; } + + [SnowflakeType(SnowflakeType.Role)] + public Snowflake Role { get; set; } = null!; +} + [UsedImplicitly] public class DynamicAWSConfig { diff --git a/InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs b/InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs index 92942b6..f379538 100644 --- a/InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs +++ b/InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs @@ -30,15 +30,15 @@ public DynamoDBEntry ToEntry(object value) * to DynamoDBv2 SDK, otherwise we'll just apply this arbitrary type converter. */ - return ToDynamoDBEntry(value); + return ToDynamoDbEntry(value); } - public object FromEntry(DynamoDBEntry entry) + public object? FromEntry(DynamoDBEntry entry) { return FromDynamoDBEntry(entry.AsDocument()); } - private static Document ToDynamoDBEntry(object obj, int maxDepth = 3, int currentDepth = 0) + private static Document ToDynamoDbEntry(object obj, int maxDepth = 3, int currentDepth = 0) { ArgumentNullException.ThrowIfNull(obj); @@ -62,10 +62,10 @@ private static Document ToDynamoDBEntry(object obj, int maxDepth = 3, int curren // Check if a DynamoDBProperty is defined on the property if (Attribute.IsDefined(property, typeof(DynamoDBPropertyAttribute))) { - var dynamoDBProperty = property.GetCustomAttribute(); - if (!string.IsNullOrEmpty(dynamoDBProperty?.AttributeName)) + var dynamoDbProperty = property.GetCustomAttribute(); + if (!string.IsNullOrEmpty(dynamoDbProperty?.AttributeName)) { - propertyName = dynamoDBProperty.AttributeName; + propertyName = dynamoDbProperty.AttributeName; } } @@ -81,56 +81,57 @@ private static Document ToDynamoDBEntry(object obj, int maxDepth = 3, int curren else { // Perform recursive or native handling - doc[propertyName] = ConvertToDynamoDBValue(propertyValue, maxDepth, currentDepth + 1); + doc[propertyName] = ConvertToDynamoDbValue(propertyValue, maxDepth, currentDepth + 1); } } return doc; } - private static DynamoDBEntry ConvertToDynamoDBValue(object value, int maxDepth, int currentDepth) + private static DynamoDBEntry ConvertToDynamoDbValue(object? value, int maxDepth, int currentDepth) { - if (value == null) return new Primitive(); - - // Handle primitive types natively supported by DynamoDB - if (value is string || value is bool || value is int || value is long || value is short || value is double || value is float || value is decimal) - { - return new Primitive - { - Value = value - }; - } - - // Handle DateTime - if (value is DateTime dateTimeVal) - { - return new Primitive - { - Value = dateTimeVal.ToString("o") - }; - } - - // Handle collections (e.g., arrays, lists) - if (value is IEnumerable enumerable) + switch (value) { - var list = new DynamoDBList(); - foreach (var element in enumerable) - { - list.Add(ConvertToDynamoDBValue(element, maxDepth, currentDepth)); - } - return list; + case null: + return new Primitive(); + // Handle primitive types natively supported by DynamoDB + case string: + case bool: + case int: + case long: + case short: + case double: + case float: + case decimal: + return new Primitive + { + Value = value + }; + // Handle DateTime + case DateTime dateTimeVal: + return new Primitive + { + Value = dateTimeVal.ToString("o") + }; + // Handle collections (e.g., arrays, lists) + case IEnumerable enumerable: + { + var list = new DynamoDBList(); + foreach (var element in enumerable) + { + list.Add(ConvertToDynamoDbValue(element, maxDepth, currentDepth)); + } + return list; + } } // Handle objects recursively - if (value.GetType().IsClass) - { - return ToDynamoDBEntry(value, maxDepth, currentDepth); - } - - throw new InvalidOperationException($"Cannot convert type {value.GetType()} to DynamoDBEntry."); + return value.GetType().IsClass + ? ToDynamoDbEntry(value, maxDepth, currentDepth) + : throw new InvalidOperationException($"Cannot convert type {value.GetType()} to DynamoDBEntry."); } - public static T FromDynamoDBEntry(Document document, int maxDepth = 3, int currentDepth = 0) where T : new() + public static TObj FromDynamoDBEntry(Document document, int maxDepth = 3, int currentDepth = 0) where TObj : new() { if (document == null) throw new ArgumentNullException(nameof(document)); @@ -138,7 +139,7 @@ private static DynamoDBEntry ConvertToDynamoDBValue(object value, int maxDepth, if (currentDepth > maxDepth) throw new InvalidOperationException("Max recursion depth reached."); - var obj = new T(); + var obj = new TObj(); foreach (var property in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)) { @@ -160,9 +161,8 @@ private static DynamoDBEntry ConvertToDynamoDBValue(object value, int maxDepth, } // Check if the document contains the property - if (!document.ContainsKey(propertyName)) continue; + if (!document.TryGetValue(propertyName, out DynamoDBEntry? entry)) continue; - var entry = document[propertyName]; var converterAttr = property.GetCustomAttribute()?.Converter; if (converterAttr != null && Activator.CreateInstance(converterAttr) is IPropertyConverter converter) @@ -210,7 +210,8 @@ private static DynamoDBEntry ConvertToDynamoDBValue(object value, int maxDepth, throw new InvalidOperationException($"Cannot determine element type for target type: {targetType}"); var enumerableType = typeof(List<>).MakeGenericType(elementType); - var resultList = (IList)Activator.CreateInstance(enumerableType); + if (Activator.CreateInstance(enumerableType) is not IList resultList) + throw new InvalidOperationException($"Failed to create an IList of target type {targetType}"); foreach (var element in list.Entries) { resultList.Add(FromDynamoDBValue(elementType, element, maxDepth, currentDepth)); diff --git a/InstarBot/DynamoModels/InstarUserData.cs b/InstarBot/DynamoModels/InstarUserData.cs index b5a09a6..f160da6 100644 --- a/InstarBot/DynamoModels/InstarUserData.cs +++ b/InstarBot/DynamoModels/InstarUserData.cs @@ -5,19 +5,32 @@ using Discord; using JetBrains.Annotations; + +// Non-nullable field must contain a non-null value when exiting constructor. +// Since this is a DTO type, we can safely ignore this warning. +#pragma warning disable CS8618 + namespace PaxAndromeda.Instar.DynamoModels; -[DynamoDBTable("TestInstarData")] +[DynamoDBTable("InstarUsers")] [SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] public class InstarUserData { - [DynamoDBHashKey("user_id", Converter = typeof(InstarSnowflakePropertyConverter))] + [DynamoDBHashKey("guild_id", Converter = typeof(InstarSnowflakePropertyConverter))] + [DynamoDBGlobalSecondaryIndexHashKey("birthdate-gsi", AttributeName = "guild_id")] + public Snowflake? GuildID { get; set; } + + [DynamoDBRangeKey("user_id", Converter = typeof(InstarSnowflakePropertyConverter))] public Snowflake? UserID { get; set; } - - [DynamoDBProperty("birthday")] - public DateTime? Birthday { get; set; } - - [DynamoDBProperty("joined")] + + [DynamoDBProperty("birthday", Converter = typeof(InstarBirthdatePropertyConverter))] + public Birthday? Birthday { get; set; } + + [DynamoDBGlobalSecondaryIndexRangeKey("birthdate-gsi", AttributeName = "birthdate")] + [DynamoDBProperty("birthdate")] + public string? Birthdate { get; set; } + + [DynamoDBProperty("joined")] public DateTime? Joined { get; set; } [DynamoDBProperty("position", Converter = typeof(InstarEnumPropertyConverter))] @@ -49,7 +62,9 @@ public string Username get => Usernames?.LastOrDefault()?.Data ?? ""; set { - var time = DateTime.UtcNow; + // We can't pass along a TimeProvider, so we'll + // need to keep DateTime.UtcNow here. + var time = DateTime.UtcNow; if (Usernames is null) { Usernames = [new InstarUserDataHistoricalEntry(time, value)]; @@ -68,6 +83,7 @@ public static InstarUserData CreateFrom(IGuildUser user) { return new InstarUserData { + GuildID = user.GuildId, UserID = user.Id, Birthday = null, Joined = user.JoinedAt?.UtcDateTime, @@ -264,4 +280,26 @@ public object FromEntry(DynamoDBEntry entry) return new Snowflake(id); } +} + +public class InstarBirthdatePropertyConverter : IPropertyConverter +{ + public DynamoDBEntry ToEntry(object value) + { + return value switch + { + DateTimeOffset dto => dto.ToString("o"), + Birthday birthday => birthday.Birthdate.ToString("o"), + _ => throw new InvalidOperationException("Invalid type for Birthdate conversion.") + }; + } + + public object FromEntry(DynamoDBEntry entry) + { + var sEntry = entry.AsString(); + if (sEntry is null || string.IsNullOrWhiteSpace(entry.AsString()) || !DateTimeOffset.TryParse(sEntry, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dto)) + return new DateTimeOffset(); + + return new Birthday(dto, TimeProvider.System); + } } \ No newline at end of file diff --git a/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs b/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs index e584198..0f00f4f 100644 --- a/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs +++ b/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs @@ -12,7 +12,7 @@ protected override EmbedBuilder BuildParts(EmbedBuilder builder) // We only have to focus on the description, author and fields here var fields = new List(); - if (eligibility.HasFlag(MembershipEligibility.NotEligible)) + if (!eligibility.HasFlag(MembershipEligibility.Eligible)) { fields.Add(new EmbedFieldBuilder() .WithName("Missing Items") @@ -43,7 +43,7 @@ private string BuildEligibilityText() eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_RolesEligibility, !eligibility.HasFlag(MembershipEligibility.MissingRoles))); eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_IntroductionEligibility, !eligibility.HasFlag(MembershipEligibility.MissingIntroduction))); - eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_JoinAgeEligibility, !eligibility.HasFlag(MembershipEligibility.TooYoung))); + eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_JoinAgeEligibility, !eligibility.HasFlag(MembershipEligibility.InadequateTenure))); eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_ModActionsEligibility, !eligibility.HasFlag(MembershipEligibility.PunishmentReceived))); eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_MessagesEligibility, !eligibility.HasFlag(MembershipEligibility.NotEnoughMessages))); @@ -81,7 +81,7 @@ from roleGroup in config.AutoMemberConfig.RequiredRoles if (eligibility.HasFlag(MembershipEligibility.MissingIntroduction)) missingItems.Add(string.Format(Strings.Command_CheckEligibility_MissingItem_Introduction, Snowflake.GetMention(() => config.AutoMemberConfig.IntroductionChannel))); - if (eligibility.HasFlag(MembershipEligibility.TooYoung)) + if (eligibility.HasFlag(MembershipEligibility.InadequateTenure)) missingItems.Add(string.Format(Strings.Command_CheckEligibility_MissingItem_TooYoung, config.AutoMemberConfig.MinimumJoinAge / 3600)); if (eligibility.HasFlag(MembershipEligibility.PunishmentReceived)) diff --git a/InstarBot/Embeds/InstarEligibilityEmbed.cs b/InstarBot/Embeds/InstarEligibilityEmbed.cs index f5dce9c..1493e52 100644 --- a/InstarBot/Embeds/InstarEligibilityEmbed.cs +++ b/InstarBot/Embeds/InstarEligibilityEmbed.cs @@ -54,7 +54,7 @@ private static string BuildEligibilityText(MembershipEligibility eligibility) eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_RolesEligibility, !eligibility.HasFlag(MembershipEligibility.MissingRoles))); eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_IntroductionEligibility, !eligibility.HasFlag(MembershipEligibility.MissingIntroduction))); - eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_JoinAgeEligibility, !eligibility.HasFlag(MembershipEligibility.TooYoung))); + eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_JoinAgeEligibility, !eligibility.HasFlag(MembershipEligibility.InadequateTenure))); eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_ModActionsEligibility, !eligibility.HasFlag(MembershipEligibility.PunishmentReceived))); eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_MessagesEligibility, !eligibility.HasFlag(MembershipEligibility.NotEnoughMessages))); diff --git a/InstarBot/Embeds/InstarPageEmbed.cs b/InstarBot/Embeds/InstarPageEmbed.cs index 8f408cb..bdf29a3 100644 --- a/InstarBot/Embeds/InstarPageEmbed.cs +++ b/InstarBot/Embeds/InstarPageEmbed.cs @@ -1,6 +1,5 @@ using Discord; using PaxAndromeda.Instar.ConfigModels; -using System.Threading.Channels; namespace PaxAndromeda.Instar.Embeds; diff --git a/InstarBot/Embeds/InstarReportUserEmbed.cs b/InstarBot/Embeds/InstarReportUserEmbed.cs index 673f4b3..4960074 100644 --- a/InstarBot/Embeds/InstarReportUserEmbed.cs +++ b/InstarBot/Embeds/InstarReportUserEmbed.cs @@ -1,6 +1,5 @@ using Discord; using PaxAndromeda.Instar.Modals; -using System; namespace PaxAndromeda.Instar.Embeds; diff --git a/InstarBot/Embeds/InstarUnderageUserWarningEmbed.cs b/InstarBot/Embeds/InstarUnderageUserWarningEmbed.cs new file mode 100644 index 0000000..896197d --- /dev/null +++ b/InstarBot/Embeds/InstarUnderageUserWarningEmbed.cs @@ -0,0 +1,28 @@ +using Discord; +using PaxAndromeda.Instar.ConfigModels; + +namespace PaxAndromeda.Instar.Embeds; + +public class InstarUnderageUserWarningEmbed(InstarDynamicConfiguration cfg, IGuildUser user, bool isMember, Birthday requestedBirthday) : InstarEmbed +{ + public override Embed Build() + { + int yearsOld = requestedBirthday.Age; + long birthdayTimestamp = requestedBirthday.Timestamp; + + return new EmbedBuilder() + // Set up all the basic stuff first + .WithCurrentTimestamp() + .WithColor(0x0c94e0) + .WithAuthor(cfg.BotName, Strings.InstarLogoUrl) + .WithFooter(new EmbedFooterBuilder() + .WithText(Strings.Embed_BirthdaySystem_Footer) + .WithIconUrl(Strings.InstarLogoUrl)) + .WithDescription(string.Format( + isMember ? Strings.Embed_UnderageUser_WarningTemplate_Member : Strings.Embed_UnderageUser_WarningTemplate_NewMember, + user.Id, + birthdayTimestamp, + yearsOld + )).Build(); + } +} \ No newline at end of file diff --git a/InstarBot/MembershipEligibility.cs b/InstarBot/MembershipEligibility.cs index faf65b3..7a2a29c 100644 --- a/InstarBot/MembershipEligibility.cs +++ b/InstarBot/MembershipEligibility.cs @@ -1,16 +1,53 @@ namespace PaxAndromeda.Instar; +/// +/// A set of flags to describe a user's eligibility for automatically granted membership. +/// [Flags] public enum MembershipEligibility { + /// + /// An invalid state. + /// Invalid = 0x0, + + /// + /// The user is eligible for membership. + /// Eligible = 0x1, - NotEligible = 0x2, - AlreadyMember = 0x4, - TooYoung = 0x8, - MissingRoles = 0x10, - MissingIntroduction = 0x20, - PunishmentReceived = 0x40, - NotEnoughMessages = 0x80, - AutoMemberHold = 0x100 + + /// + /// The user is already a member. + /// + AlreadyMember = 0x2, + + /// + /// The user has not been on the server for long enough. + /// + InadequateTenure = 0x4, + + /// + /// The user is missing required roles. + /// + MissingRoles = 0x8, + + /// + /// The user has not posted an introduction. + /// + MissingIntroduction = 0x10, + + /// + /// The user has received some form of punishment precluding a membership grant. + /// + PunishmentReceived = 0x20, + + /// + /// The user has not sent enough messages on the server. + /// + NotEnoughMessages = 0x40, + + /// + /// The user's membership has been manually withheld. + /// + AutoMemberHold = 0x80 } \ No newline at end of file diff --git a/InstarBot/Metrics/Metric.cs b/InstarBot/Metrics/Metric.cs index 036a2a4..59104d1 100644 --- a/InstarBot/Metrics/Metric.cs +++ b/InstarBot/Metrics/Metric.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using JetBrains.Annotations; namespace PaxAndromeda.Instar.Metrics; @@ -46,6 +45,14 @@ public enum Metric [MetricName("AMH Application Failures")] AMS_AMHFailures, + [MetricDimension("Service", "Birthday System")] + [MetricName("Birthday System Failures")] + BirthdaySystem_Failures, + + [MetricDimension("Service", "Birthday System")] + [MetricName("Birthday Grants")] + BirthdaySystem_Grants, + [MetricDimension("Service", "Discord")] [MetricName("Messages Sent")] Discord_MessagesSent, @@ -60,6 +67,13 @@ public enum Metric [MetricDimension("Service", "Discord")] [MetricName("Users Left")] - [UsedImplicitly] - Discord_UsersLeft + Discord_UsersLeft, + + [MetricDimension("Service", "Gaius")] + [MetricName("Gaius API Calls")] + Gaius_APICalls, + + [MetricDimension("Service", "Gaius")] + [MetricName("Gaius API Latency")] + Gaius_APILatency } \ No newline at end of file diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index b1bfbd7..81240c8 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -23,17 +23,18 @@ public static async Task Main(string[] args) { AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException; - var cli = Parser.Default.ParseArguments(args).Value; #if DEBUG var configPath = "Config/Instar.debug.conf.json"; #else + var cli = Parser.Default.ParseArguments(args).Value; + var configPath = "Config/Instar.conf.json"; if (!string.IsNullOrEmpty(cli.ConfigPath)) configPath = cli.ConfigPath; #endif - - Log.Information("Config path is {Path}", configPath); + + Log.Information("Config path is {Path}", configPath); IConfiguration config = new ConfigurationBuilder() .AddJsonFile(configPath) .Build(); @@ -68,9 +69,17 @@ private static async Task RunAsync(IConfiguration config) var dynamicConfig = _services.GetRequiredService(); await dynamicConfig.Initialize(); - var discordService = _services.GetRequiredService(); + var discordService = _services.GetRequiredService(); await discordService.Start(_services); - } + + // Start up other systems + List tasks = [ + _services.GetRequiredService().Initialize(), + _services.GetRequiredService().Initialize() + ]; + + Task.WaitAll(tasks); + } private static void InitializeLogger(IConfiguration config) { @@ -121,14 +130,21 @@ private static ServiceProvider ConfigureServices(IConfiguration config) // Services services.AddSingleton(); services.AddTransient(); - services.AddTransient(); services.AddTransient(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // Commands & Interactions - services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(TimeProvider.System); + +#if DEBUG + services.AddSingleton(); +#else + services.AddTransient(); +#endif + + // Commands & Interactions + services.AddTransient(); services.AddTransient(); services.AddSingleton(); services.AddTransient(); diff --git a/InstarBot/Services/AWSDynamicConfigService.cs b/InstarBot/Services/AWSDynamicConfigService.cs index c994d8f..e71edaf 100644 --- a/InstarBot/Services/AWSDynamicConfigService.cs +++ b/InstarBot/Services/AWSDynamicConfigService.cs @@ -22,7 +22,8 @@ public interface IDynamicConfigService public sealed class AWSDynamicConfigService : IDynamicConfigService { - private readonly AmazonAppConfigDataClient _appConfigDataClient; + private readonly TimeProvider _timeProvider; + private readonly AmazonAppConfigDataClient _appConfigDataClient; private readonly AmazonSimpleSystemsManagementClient _ssmClient; private string _configData = null!; private string _nextToken = null!; @@ -34,9 +35,10 @@ public sealed class AWSDynamicConfigService : IDynamicConfigService private readonly string _environment; private readonly string _configProfile; - public AWSDynamicConfigService(IConfiguration config) + public AWSDynamicConfigService(IConfiguration config, TimeProvider timeProvider) { - Guard.Against.Null(config); + _timeProvider = timeProvider; + Guard.Against.Null(config); var awsSection = config.GetSection("AWS"); var appConfigSection = awsSection.GetSection("AppConfig"); @@ -64,7 +66,7 @@ public async Task GetConfig() try { await _pollSemaphore.WaitAsync(); - if (DateTime.UtcNow > _nextPollTime) + if (_timeProvider.GetUtcNow().UtcDateTime > _nextPollTime) await Poll(false); return _current; @@ -111,7 +113,7 @@ private async Task Poll(bool bypass) }); _nextToken = result.NextPollConfigurationToken; - _nextPollTime = DateTime.UtcNow + TimeSpan.FromSeconds((double)(result.NextPollIntervalInSeconds ?? 60)); + _nextPollTime = _timeProvider.GetUtcNow().UtcDateTime + TimeSpan.FromSeconds((double)(result.NextPollIntervalInSeconds ?? 60)); // Per the documentation, if VersionLabel is empty, then the client // has the most up-to-date configuration already stored. We can stop diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index c35575c..d2c5d72 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -6,6 +6,7 @@ using PaxAndromeda.Instar.Caching; using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Gaius; using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Modals; using Serilog; @@ -27,6 +28,7 @@ public sealed class AutoMemberSystem : IAutoMemberSystem private readonly IGaiusAPIService _gaiusApiService; private readonly IInstarDDBService _ddbService; private readonly IMetricService _metricService; + private readonly TimeProvider _timeProvider; private Timer _timer = null!; /// @@ -35,27 +37,27 @@ public sealed class AutoMemberSystem : IAutoMemberSystem private Dictionary? _recentMessages; public AutoMemberSystem(IDynamicConfigService dynamicConfig, IDiscordService discord, IGaiusAPIService gaiusApiService, - IInstarDDBService ddbService, IMetricService metricService) + IInstarDDBService ddbService, IMetricService metricService, TimeProvider timeProvider) { _dynamicConfig = dynamicConfig; _discord = discord; _gaiusApiService = gaiusApiService; _ddbService = ddbService; _metricService = metricService; + _timeProvider = timeProvider; discord.UserJoined += HandleUserJoined; + discord.UserLeft += HandleUserLeft; discord.UserUpdated += HandleUserUpdated; discord.MessageReceived += HandleMessageReceived; discord.MessageDeleted += HandleMessageDeleted; - - Task.Run(Initialize).Wait(); } - private async Task Initialize() + public async Task Initialize() { var cfg = await _dynamicConfig.GetConfig(); - _earliestJoinTime = DateTime.UtcNow - TimeSpan.FromSeconds(cfg.AutoMemberConfig.MinimumJoinAge); + _earliestJoinTime = _timeProvider.GetUtcNow().UtcDateTime - TimeSpan.FromSeconds(cfg.AutoMemberConfig.MinimumJoinAge); await PreloadMessageCache(cfg); await PreloadIntroductionPosters(cfg); @@ -66,18 +68,49 @@ private async Task Initialize() StartTimer(); } + /// + /// Filters warnings and caselogs to only focus on the ones we care about. For example, we don't + /// want to withhold membership from someone who was kicked for having a too new Discord account. + /// + /// A collection of warnings retrieved from the Gaius API. + /// A collection of caselogs retrieved from the Gaius API. + /// A tuple containing the filtered warnings and caselogs, respectively. + /// If or is null. + private static (IEnumerable, IEnumerable) FilterPunishments(IEnumerable warnings, IEnumerable caselogs) + { + if (warnings is null) + throw new ArgumentNullException(nameof(warnings)); + if (caselogs is null) + throw new ArgumentNullException(nameof(caselogs)); + + var filteredCaselogs = caselogs.Where(n => n is not { Type: CaselogType.Kick, Reason: "Join age punishment" }); + + return (warnings, filteredCaselogs); + } + private async Task UpdateGaiusPunishments() { // Normally we'd go for 1 hour here, but we can run into // a situation where someone was warned exactly 1.000000001 // hours ago, thus would be missed. To fix this, we'll // bias for an hour and a half ago. - var afterTime = DateTime.UtcNow - TimeSpan.FromHours(1.5); - - foreach (var warning in await _gaiusApiService.GetWarningsAfter(afterTime)) - _punishedUsers.TryAdd(warning.UserID.ID, true); - foreach (var caselog in await _gaiusApiService.GetCaselogsAfter(afterTime)) - _punishedUsers.TryAdd(caselog.UserID.ID, true); + var afterTime = _timeProvider.GetUtcNow().UtcDateTime - TimeSpan.FromHours(1.5); + + try + { + var (warnings, caselogs) = FilterPunishments( + await _gaiusApiService.GetWarningsAfter(afterTime), + await _gaiusApiService.GetCaselogsAfter(afterTime)); + + foreach (var warning in warnings) + _punishedUsers.TryAdd(warning.UserID.ID, true); + + foreach (var caselog in caselogs) + _punishedUsers.TryAdd(caselog.UserID.ID, true); + } catch (Exception ex) + { + Log.Error(ex, "Failed to update Gaius punishments."); + } } private async Task HandleMessageDeleted(Snowflake arg) @@ -148,8 +181,13 @@ private async Task HandleUserJoined(IGuildUser user) } await _metricService.Emit(Metric.Discord_UsersJoined, 1); - } - + } + private async Task HandleUserLeft(IUser arg) + { + // TODO: Maybe handle something here later + await _metricService.Emit(Metric.Discord_UsersLeft, 1); + } + private async Task HandleUserUpdated(UserUpdatedEventArgs arg) { if (!arg.HasUpdated) @@ -183,7 +221,7 @@ private async Task HandleUserUpdated(UserUpdatedEventArgs arg) if (arg.Before.Nickname != arg.After.Nickname) { - user.Data.Nicknames?.Add(new InstarUserDataHistoricalEntry(DateTime.UtcNow, arg.After.Nickname)); + user.Data.Nicknames?.Add(new InstarUserDataHistoricalEntry(_timeProvider.GetUtcNow().UtcDateTime, arg.After.Nickname)); changed = true; } @@ -196,19 +234,22 @@ private async Task HandleUserUpdated(UserUpdatedEventArgs arg) private void StartTimer() { - // Since we can start the bot in the middle of an hour, - // first we must determine the time until the next top - // of hour. - var nextHour = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day, - DateTime.UtcNow.Hour, 0, 0).AddHours(1); - var millisecondsRemaining = (nextHour - DateTime.UtcNow).TotalMilliseconds; + // Since we can start the bot in the middle of an hour, + // first we must determine the time until the next top + // of hour. + var currentTime = _timeProvider.GetUtcNow().UtcDateTime; + var nextHour = new DateTime(currentTime.Year, currentTime.Month, currentTime.Day, + currentTime.Hour, 0, 0).AddHours(1); + var millisecondsRemaining = (nextHour - currentTime).TotalMilliseconds; // Start the timer. In elapsed step, we reset the // duration to exactly 1 hour. _timer = new Timer(millisecondsRemaining); _timer.Elapsed += TimerElapsed; _timer.Start(); - } + + Log.Information("Auto member system timer started, first run in {SecondsRemaining} seconds.", millisecondsRemaining / 1000); + } private async void TimerElapsed(object? sender, ElapsedEventArgs e) { @@ -241,7 +282,7 @@ public async Task RunAsync() await UpdateGaiusPunishments(); } - _earliestJoinTime = DateTime.UtcNow - TimeSpan.FromSeconds(cfg.AutoMemberConfig.MinimumJoinAge); + _earliestJoinTime = _timeProvider.GetUtcNow().UtcDateTime - TimeSpan.FromSeconds(cfg.AutoMemberConfig.MinimumJoinAge); _recentMessages = GetMessagesSent(); Log.Verbose("Earliest join time: {EarliestJoinTime}", _earliestJoinTime); @@ -382,7 +423,7 @@ public MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IG eligibility |= MembershipEligibility.AlreadyMember; if (user.JoinedAt > _earliestJoinTime) - eligibility |= MembershipEligibility.TooYoung; + eligibility |= MembershipEligibility.InadequateTenure; if (!CheckUserRequiredRoles(cfg, user)) eligibility |= MembershipEligibility.MissingRoles; @@ -399,15 +440,12 @@ public MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IG if (user.RoleIds.Contains(cfg.AutoMemberConfig.HoldRole)) eligibility |= MembershipEligibility.AutoMemberHold; + // If the eligibility is no longer exactly Eligible, + // then we can unset that flag. if (eligibility != MembershipEligibility.Eligible) - { - // Unset Eligible flag, add NotEligible flag. - // OPTIMIZE: Do we need the NotEligible flag at all? eligibility &= ~MembershipEligibility.Eligible; - eligibility |= MembershipEligibility.NotEligible; - } - Log.Verbose("User {User} ({UserID}) membership eligibility: {Eligibility}", user.Username, user.Id, eligibility); + Log.Verbose("User {User} ({UserID}) membership eligibility: {Eligibility}", user.Username, user.Id, eligibility); return eligibility; } @@ -442,17 +480,29 @@ private Dictionary GetMessagesSent() private async Task PreloadGaiusPunishments() { - foreach (var warning in await _gaiusApiService.GetAllWarnings()) - _punishedUsers.TryAdd(warning.UserID.ID, true); - foreach (var caselog in await _gaiusApiService.GetAllCaselogs()) - _punishedUsers.TryAdd(caselog.UserID.ID, true); + try + { + var (warnings, caselogs) = FilterPunishments( + await _gaiusApiService.GetAllWarnings(), + await _gaiusApiService.GetAllCaselogs()); + + foreach (var warning in warnings) + _punishedUsers.TryAdd(warning.UserID.ID, true); + foreach (var caselog in caselogs) + _punishedUsers.TryAdd(caselog.UserID.ID, true); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to preload Gaius punishments."); + throw; + } } private async Task PreloadMessageCache(InstarDynamicConfiguration cfg) { Log.Information("Preloading message cache..."); var guild = _discord.GetGuild(); - var earliestMessageTime = DateTime.UtcNow - TimeSpan.FromSeconds(cfg.AutoMemberConfig.MinimumMessageTime); + var earliestMessageTime = _timeProvider.GetUtcNow().UtcDateTime - TimeSpan.FromSeconds(cfg.AutoMemberConfig.MinimumMessageTime); var messages = _discord.GetMessages(guild, earliestMessageTime); await foreach (var message in messages) diff --git a/InstarBot/Services/BirthdaySystem.cs b/InstarBot/Services/BirthdaySystem.cs new file mode 100644 index 0000000..87b37ab --- /dev/null +++ b/InstarBot/Services/BirthdaySystem.cs @@ -0,0 +1,276 @@ +using System.Diagnostics.CodeAnalysis; +using Ardalis.GuardClauses; +using Discord; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using Serilog; +using Metric = PaxAndromeda.Instar.Metrics.Metric; + +namespace PaxAndromeda.Instar.Services; +using System.Timers; + +public sealed class BirthdaySystem ( + IDynamicConfigService dynamicConfig, + IDiscordService discord, + IInstarDDBService ddbService, + IMetricService metricService, + TimeProvider timeProvider) + : IBirthdaySystem +{ + /// + /// The maximum age to be considered 'valid' for age role assignment. + /// + private const int MaximumAge = 150; + + private Timer _timer = null!; + + [ExcludeFromCodeCoverage] + public Task Initialize() + { + StartTimer(); + return Task.CompletedTask; + } + + [ExcludeFromCodeCoverage] + private void StartTimer() + { + // We need to run the birthday check every 30 minutes + // to accomodate any time zone differences. + + var currentTime = timeProvider.GetUtcNow().UtcDateTime; + + bool firstHalfHour = currentTime.Minute < 30; + DateTime currentHour = new (currentTime.Year, currentTime.Month, currentTime.Day, + currentTime.Hour, 0, 0, DateTimeKind.Utc); + + DateTime firstRun = firstHalfHour ? currentHour.AddMinutes(30) : currentHour.AddHours(1); + + var millisecondsRemaining = (firstRun - currentTime).TotalMilliseconds; + + _timer = new Timer(millisecondsRemaining); + _timer.Elapsed += TimerElapsed; + _timer.Start(); + + + + Log.Information("Birthday system timer started, first run in {SecondsRemaining} seconds.", millisecondsRemaining / 1000); + } + + [ExcludeFromCodeCoverage] + private async void TimerElapsed(object? sender, ElapsedEventArgs e) + { + try + { + // Ensure the timer's interval is exactly 30 minutes. + _timer.Interval = 30 * 60 * 1000; + + await RunAsync(); + } + catch + { + // ignore + } + } + + public async Task RunAsync() + { + var cfg = await dynamicConfig.GetConfig(); + var currentTime = timeProvider.GetUtcNow().UtcDateTime; + + await RemoveBirthdays(cfg, currentTime); + var successfulAdds = await GrantBirthdays(cfg, currentTime); + + await metricService.Emit(Metric.BirthdaySystem_Grants, successfulAdds.Count); + + // Now we can create a happy announcement message + if (successfulAdds.Count == 0) + return; + + // Now let's draft up a birthday message + await AnnounceBirthdays(cfg, successfulAdds); + } + + private async Task AnnounceBirthdays(InstarDynamicConfiguration cfg, IEnumerable users) + { + var channel = await discord.GetChannel(cfg.BirthdayConfig.BirthdayAnnounceChannel.ID); + if (channel is not ITextChannel textChannel) + { + Log.Error("Cannot send birthday announcement, channel {ChannelID} not found.", cfg.BirthdayConfig.BirthdayAnnounceChannel.ID); + return; + } + + string mentions = Conjoin(users.Select(s => $"<@{s.ID}>").ToList()); + string message = string.Format(Strings.Birthday_Announcement, cfg.BirthdayConfig.BirthdayRole.ID, mentions); + + await textChannel.SendMessageAsync(message); + } + + private static string Conjoin(IList strings) + { + return strings.Count switch + { + 0 => string.Empty, + 1 => strings[0], + 2 => $"{strings[0]} {Strings.JoiningConjunction} {strings[1]}", + _ => string.Join(", ", strings.Take(strings.Count - 1)) + $", {Strings.JoiningConjunction} {strings.Last()}" + }; + } + + private async Task RemoveBirthdays(InstarDynamicConfiguration cfg, DateTime currentTime) + { + var currentAppliedUsers = discord.GetAllUsersWithRole(cfg.BirthdayConfig.BirthdayRole).Select(n => new Snowflake(n.Id)); + + var batchedUsers = await ddbService.GetBatchUsersAsync(currentAppliedUsers); + + List toRemove = [ ]; + foreach (var user in batchedUsers) + { + if (user.Data.Birthday is null) + { + toRemove.Add(user.Data.UserID!); + continue; + } + + var birthDate = user.Data.Birthday.Birthdate; + + var thisYearBirthday = new DateTime( + currentTime.Year, + birthDate.Month, birthDate.Day, birthDate.Hour, birthDate.Minute, 0, DateTimeKind.Utc); + + if (thisYearBirthday > currentTime) + thisYearBirthday = thisYearBirthday.AddYears(-1); + + + if (currentTime - thisYearBirthday >= TimeSpan.FromDays(1)) + toRemove.Add(user.Data.UserID!); + } + + foreach (var snowflake in toRemove) + { + var guildUser = discord.GetUser(snowflake); + if (guildUser is null) + { + Log.Warning("Cannot grant birthday role to {UserID} as they were not found on the server.", snowflake.ID); + continue; + } + + await guildUser.RemoveRoleAsync(cfg.BirthdayConfig.BirthdayRole); + } + } + + private async Task> GrantBirthdays(InstarDynamicConfiguration cfg, DateTime currentTime) + { + List> dbResults = [ ]; + + await discord.SyncUsers(); + + try + { + // Get all users with birthdays ±15 minutes from the current time. + // BUG: could be off if the timer drifts. maybe we need a metric for expected runtime vs actual runtime? + dbResults.AddRange(await ddbService.GetUsersByBirthday(currentTime, TimeSpan.FromMinutes(10))); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to run birthday routine"); + await metricService.Emit(Metric.BirthdaySystem_Failures, 1); + return [ ]; + } + + // list of user IDs to mention in the happy birthday message + List toMention = [ ]; + + foreach (var result in dbResults) + { + var userId = result.Data.UserID; + if (userId is null) + continue; + + try + { + var guildUser = discord.GetUser(userId); + if (guildUser is null) + { + Log.Warning("Cannot grant birthday role to {UserID} as they were not found on the server.", userId.ID); + continue; + } + + toMention.Add(userId); + + await GrantBirthdayRole(cfg, guildUser, result.Data.Birthday); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to apply birthday role to {UserID}", userId.ID); + } + } + + return toMention; + } + + private static async Task UpdateAgeRole(InstarDynamicConfiguration cfg, IGuildUser user, int ageYears) + { + Guard.Against.Null(cfg); + Guard.Against.Null(user); + + // TODO: update this whenever someone in the world turns 200 years old + Guard.Against.OutOfRange(ageYears, nameof(ageYears), 0, 200); + + // If the user's age is below the youngest age role, there's nothing to do + if (ageYears < cfg.BirthdayConfig.AgeRoleMap.Min(n => n.Age)) + return; + + // Find the appropriate age role to assign. If the current age exceeds + // the maximum age role mapping, we assign the maximum age role. + Snowflake? roleToAssign; + + var maxAgeMap = cfg.BirthdayConfig.AgeRoleMap.MaxBy(n => n.Age); + if (maxAgeMap is null) + throw new BadStateException("Failed to read age role map from dynamic configuration"); + + if (ageYears >= maxAgeMap.Age) + { + roleToAssign = maxAgeMap.Role; + } else + { + // Find the closest age role that does not exceed the user's age + roleToAssign = cfg.BirthdayConfig.AgeRoleMap + .Where(n => n.Age <= ageYears) + .OrderByDescending(n => n.Age) + .Select(n => n.Role) + .FirstOrDefault(); + } + + // Maybe missing a mapping somewhere? + if (roleToAssign is null) + { + Log.Warning("Failed to find appropriate age role for user who is {UserAge} years old.", ageYears); + return; + } + + // We need to identify every age role the user has and remove them first + await user.RemoveRolesAsync(cfg.BirthdayConfig.AgeRoleMap.Select(n => n.Role.ID).Where(user.RoleIds.Contains)); + await user.AddRoleAsync(roleToAssign); + } + + private async Task GrantBirthdayRole(InstarDynamicConfiguration cfg, IGuildUser user, Birthday? birthday) + { + await user.AddRoleAsync(cfg.BirthdayConfig.BirthdayRole); + + if (birthday is null) + return; + + int yearsOld = birthday.Age; + + if (yearsOld < MaximumAge) + await UpdateAgeRole(cfg, user, yearsOld); + } + + public async Task GrantUnexpectedBirthday(IGuildUser user, Birthday birthday) + { + var cfg = await dynamicConfig.GetConfig(); + + await GrantBirthdayRole(cfg, user, birthday); + await AnnounceBirthdays(cfg, [new Snowflake(user.Id)]); + } +} \ No newline at end of file diff --git a/InstarBot/Services/CloudwatchMetricService.cs b/InstarBot/Services/CloudwatchMetricService.cs index 6dc9b70..94d5a64 100644 --- a/InstarBot/Services/CloudwatchMetricService.cs +++ b/InstarBot/Services/CloudwatchMetricService.cs @@ -2,6 +2,7 @@ using Amazon; using Amazon.CloudWatch; using Amazon.CloudWatch.Model; +using Amazon.Runtime; using Microsoft.Extensions.Configuration; using PaxAndromeda.Instar.Metrics; using Serilog; @@ -11,7 +12,11 @@ namespace PaxAndromeda.Instar.Services; public sealed class CloudwatchMetricService : IMetricService { - private readonly AmazonCloudWatchClient _client; + // Exponential backoff parameters + private const int MaxAttempts = 5; + private static readonly TimeSpan BaseDelay = TimeSpan.FromMilliseconds(200); + + private readonly AmazonCloudWatchClient _client; private readonly string _metricNamespace; public CloudwatchMetricService(IConfiguration config) { @@ -23,40 +28,71 @@ public CloudwatchMetricService(IConfiguration config) public async Task Emit(Metric metric, double value) { - try - { - var nameAttr = metric.GetAttributeOfType(); - - var datum = new MetricDatum - { - MetricName = nameAttr is not null ? nameAttr.Name : Enum.GetName(metric), - Value = value, - Dimensions = [] - }; - - var attrs = metric.GetAttributesOfType(); - if (attrs != null) - foreach (var dim in attrs) - { - datum.Dimensions.Add(new Dimension - { - Name = dim.Name, - Value = dim.Value - }); - } - - var response = await _client.PutMetricDataAsync(new PutMetricDataRequest - { - Namespace = _metricNamespace, - MetricData = [datum] - }); - - return response.HttpStatusCode == HttpStatusCode.OK; - } - catch (Exception ex) - { - Log.Error(ex, "Failed to emit metric {Metric} with value {Value}", metric, value); - return false; - } - } + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + var nameAttr = metric.GetAttributeOfType(); + + var datum = new MetricDatum + { + MetricName = nameAttr is not null ? nameAttr.Name : Enum.GetName(metric), + Value = value, + Dimensions = [] + }; + + var attrs = metric.GetAttributesOfType(); + if (attrs != null) + foreach (var dim in attrs) + { + datum.Dimensions.Add(new Dimension + { + Name = dim.Name, + Value = dim.Value + }); + } + + var response = await _client.PutMetricDataAsync(new PutMetricDataRequest + { + Namespace = _metricNamespace, + MetricData = [datum] + }); + + return response.HttpStatusCode == HttpStatusCode.OK; + } catch (Exception ex) when (IsTransient(ex) && attempt < MaxAttempts) + { + var expo = Math.Pow(2, attempt - 1); + var jitter = TimeSpan.FromMilliseconds(new Random().NextDouble() * 100); + var delay = TimeSpan.FromMilliseconds(BaseDelay.TotalMilliseconds * expo) + jitter; + + await Task.Delay(delay); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to emit metric {Metric} with value {Value}", metric, value); + return false; + } + } + + // If we exit the loop without returning, it failed after retries. + Log.Error("Exceeded retry attempts emitting metric {Metric} with value {Value}", metric, value); + return false; + } + + private static bool IsTransient(Exception ex) + { + return ex switch + { + AmazonServiceException ase => + // 5xx errors / throttles are transient + ase.StatusCode == HttpStatusCode.InternalServerError || (int) ase.StatusCode >= 500 || + ase.ErrorCode.Contains("Throttling", StringComparison.OrdinalIgnoreCase) || + ase.ErrorCode.Contains("Throttled", StringComparison.OrdinalIgnoreCase), + // clientside issues + AmazonClientException or WebException => true, + // gracefully handle cancels + OperationCanceledException or TaskCanceledException => false, + _ => ex is TimeoutException + }; + } } \ No newline at end of file diff --git a/InstarBot/Services/DiscordService.cs b/InstarBot/Services/DiscordService.cs index c2fbfe6..0c5c66e 100644 --- a/InstarBot/Services/DiscordService.cs +++ b/InstarBot/Services/DiscordService.cs @@ -24,9 +24,11 @@ public sealed class DiscordService : IDiscordService private readonly IDynamicConfigService _dynamicConfig; private readonly DiscordSocketClient _socketClient; private readonly AsyncEvent _userJoinedEvent = new(); + private readonly AsyncEvent _userLeftEvent = new(); private readonly AsyncEvent _userUpdatedEvent = new(); private readonly AsyncEvent _messageReceivedEvent = new(); private readonly AsyncEvent _messageDeletedEvent = new(); + private readonly AsyncAutoResetEvent _readyEvent = new(false); public event Func UserJoined { @@ -34,6 +36,12 @@ public event Func UserJoined remove => _userJoinedEvent.Remove(value); } + public event Func UserLeft + { + add => _userLeftEvent.Add(value); + remove => _userLeftEvent.Remove(value); + } + public event Func UserUpdated { add => _userUpdatedEvent.Add(value); @@ -78,6 +86,7 @@ public DiscordService(IServiceProvider provider, IConfiguration config, IDynamic _socketClient.MessageReceived += async message => await _messageReceivedEvent.Invoke(message); _socketClient.MessageDeleted += async (msgCache, _) => await _messageDeletedEvent.Invoke(msgCache.Id); _socketClient.UserJoined += async user => await _userJoinedEvent.Invoke(user); + _socketClient.UserLeft += async (_, user) => await _userLeftEvent.Invoke(user); _socketClient.GuildMemberUpdated += HandleUserUpdate; _interactionService.Log += HandleDiscordLog; @@ -90,7 +99,7 @@ public DiscordService(IServiceProvider provider, IConfiguration config, IDynamic throw new ConfigurationException("TargetGuild is not set"); } - private async Task HandleUserUpdate(Cacheable before, SocketGuildUser after) + private async Task HandleUserUpdate(Cacheable before, SocketGuildUser after) { // Can't do anything if we don't have a before state. // In the case of a user join, that is handled in UserJoined event. @@ -163,15 +172,20 @@ public async Task Start(IServiceProvider provider) .Cast().ToArray(); await _socketClient.BulkOverwriteGlobalApplicationCommandsAsync(props); - }; + _readyEvent.Set(); + + }; Log.Verbose("Attempting login..."); await _socketClient.LoginAsync(TokenType.Bot, _botToken); Log.Verbose("Starting Discord..."); await _socketClient.StartAsync(); - } - [SuppressMessage("ReSharper", "TemplateIsNotCompileTimeConstantProblem")] + // Wait until ready + await _readyEvent.WaitAsync(); + } + + [SuppressMessage("ReSharper", "TemplateIsNotCompileTimeConstantProblem")] private static Task HandleDiscordLog(LogMessage arg) { var severity = arg.Severity switch @@ -216,20 +230,50 @@ public async Task> GetAllUsers() return []; } } - - public async IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime) + + public async Task SyncUsers() + { + try + { + var guild = _socketClient.GetGuild(_guild); + await _socketClient.DownloadUsersAsync([guild]); + } catch (Exception ex) + { + Log.Error(ex, "Failed to download users for guild {GuildID}", _guild); + } + } + + public IGuildUser? GetUser(Snowflake snowflake) + { + try + { + var guild = _socketClient.GetGuild(_guild); + + return guild.GetUser(snowflake); + } catch (Exception ex) + { + Log.Error(ex, "Failed to get user {UserID} in guild {GuildID}", snowflake.ID, _guild); + return null; + } + } + + public IEnumerable GetAllUsersWithRole(Snowflake roleId) + { + var guild = _socketClient.GetGuild(_guild); + + return guild.GetRole(roleId).Members; + } + + public async IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime) { Log.Debug("GetMessages({Guild}, {AfterTime})", guild.Id, afterTime); foreach (var channel in guild.TextChannels) { Log.Debug("Downloading #{Channel}", channel.Name); - // Reference message will be the "current" - // message we are looking at. Since the - // GetMessagesAsync() method returns messages - // in order of newest to oldest, we can keep - // a running log of the oldest message we've - // encountered. + // Reference message will be the "current" message we are looking at. Since the + // GetMessagesAsync() method returns messages in order of newest to oldest, we can keep + // a running log of the oldest message we've encountered. var refMessage = (await channel.GetMessagesAsync(1).FlattenAsync()).FirstOrDefault(); if (refMessage is null) continue; diff --git a/InstarBot/Services/FileSystemMetricService.cs b/InstarBot/Services/FileSystemMetricService.cs new file mode 100644 index 0000000..f1d307b --- /dev/null +++ b/InstarBot/Services/FileSystemMetricService.cs @@ -0,0 +1,28 @@ +using System.Reflection; +using PaxAndromeda.Instar.Metrics; + +namespace PaxAndromeda.Instar.Services; + +public class FileSystemMetricService : IMetricService +{ + public FileSystemMetricService() + { + Initialize(); + } + + public void Initialize() + { + // Initialize the metrics subdirectory + // .GetEntryAssembly() can be null in some unmanaged contexts, but that + // doesn't apply here. + var currentDirectory = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location)!; + + Directory.CreateDirectory(Path.Combine(currentDirectory, "metrics")); + } + + public Task Emit(Metric metric, double value) + { + + return Task.FromResult(true); + } +} \ No newline at end of file diff --git a/InstarBot/Services/GaiusAPIService.cs b/InstarBot/Services/GaiusAPIService.cs index 48c4216..978b18f 100644 --- a/InstarBot/Services/GaiusAPIService.cs +++ b/InstarBot/Services/GaiusAPIService.cs @@ -1,10 +1,12 @@ +using System.Diagnostics; using Newtonsoft.Json; using PaxAndromeda.Instar.Gaius; using System.Text; +using PaxAndromeda.Instar.Metrics; namespace PaxAndromeda.Instar.Services; -public sealed class GaiusAPIService(IDynamicConfigService config) : IGaiusAPIService +public sealed class GaiusAPIService(IDynamicConfigService config, IMetricService metrics) : IGaiusAPIService { // Used in release mode // ReSharper disable once NotAccessedField.Local @@ -135,8 +137,14 @@ private static IEnumerable ParseCaselogs(string response) private async Task Get(string url) { var hrm = CreateRequest(url); + await metrics.Emit(Metric.Gaius_APICalls, 1); + + var stopwatch = Stopwatch.StartNew(); var response = await _client.SendAsync(hrm); - + stopwatch.Stop(); + + await metrics.Emit(Metric.Gaius_APILatency, stopwatch.Elapsed.TotalMilliseconds); + return await response.Content.ReadAsStringAsync(); } diff --git a/InstarBot/Services/IAutoMemberSystem.cs b/InstarBot/Services/IAutoMemberSystem.cs index 5490a28..ee221dd 100644 --- a/InstarBot/Services/IAutoMemberSystem.cs +++ b/InstarBot/Services/IAutoMemberSystem.cs @@ -25,4 +25,6 @@ public interface IAutoMemberSystem /// /// MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IGuildUser user); + + Task Initialize(); } \ No newline at end of file diff --git a/InstarBot/Services/IBirthdaySystem.cs b/InstarBot/Services/IBirthdaySystem.cs new file mode 100644 index 0000000..631f9f6 --- /dev/null +++ b/InstarBot/Services/IBirthdaySystem.cs @@ -0,0 +1,18 @@ +using Discord; + +namespace PaxAndromeda.Instar.Services; + +public interface IBirthdaySystem +{ + Task Initialize(); + Task RunAsync(); + + /// + /// Grants the birthday role to a user outside the normal birthday check process. For example, a + /// user sets their birthday to today via command. + /// + /// The user to grant the birthday role to. + /// The user's birthday. + /// Nothing. + Task GrantUnexpectedBirthday(IGuildUser user, Birthday birthday); +} \ No newline at end of file diff --git a/InstarBot/Services/IDiscordService.cs b/InstarBot/Services/IDiscordService.cs index 49d312b..fe6c5f9 100644 --- a/InstarBot/Services/IDiscordService.cs +++ b/InstarBot/Services/IDiscordService.cs @@ -5,7 +5,17 @@ namespace PaxAndromeda.Instar.Services; public interface IDiscordService { - event Func UserJoined; + /// + /// Occurs when a user joins the guild. + /// + event Func UserJoined; + /// + /// Occurs when a user leaves the guild. + /// + event Func UserLeft; + /// + /// Occurs when a user's details are updated, either by the user or otherwise. + /// event Func UserUpdated; event Func MessageReceived; event Func MessageDeleted; @@ -15,4 +25,7 @@ public interface IDiscordService Task> GetAllUsers(); Task GetChannel(Snowflake channelId); IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime); + IGuildUser? GetUser(Snowflake snowflake); + IEnumerable GetAllUsersWithRole(Snowflake roleId); + Task SyncUsers(); } \ No newline at end of file diff --git a/InstarBot/Services/IInstarDDBService.cs b/InstarBot/Services/IInstarDDBService.cs index 40e61af..1bcae19 100644 --- a/InstarBot/Services/IInstarDDBService.cs +++ b/InstarBot/Services/IInstarDDBService.cs @@ -41,4 +41,18 @@ public interface IInstarDDBService /// An instance of to save into DynamoDB. /// Nothing. Task CreateUserAsync(InstarUserData data); + + /// + /// Retrieves a list of users whose birthdays match the specified date, allowing for a margin of error defined by the + /// fuzziness parameter. + /// + /// The search includes users whose birthdays are within the specified fuzziness window before or after + /// the given date. This method performs the comparison in UTC to ensure consistency across time zones. + /// The birthdate to search for. Represents the target date to match against user birthdays. + /// The allowable time range, as a , within which user birthdays are considered a match. Must be + /// non-negative. + /// A task that represents the asynchronous operation. The task result contains a list of objects for users whose birthdays fall within the specified range. + /// Returns an empty list if no users are found. + Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness); } \ No newline at end of file diff --git a/InstarBot/Services/InstarDDBService.cs b/InstarBot/Services/InstarDDBService.cs index 3d8bf79..da3f2b9 100644 --- a/InstarBot/Services/InstarDDBService.cs +++ b/InstarBot/Services/InstarDDBService.cs @@ -1,22 +1,29 @@ -using System.Diagnostics.CodeAnalysis; -using Amazon; +using Amazon; using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; using Discord; using Microsoft.Extensions.Configuration; using PaxAndromeda.Instar.DynamoModels; using Serilog; +using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Services; [ExcludeFromCodeCoverage] public sealed class InstarDDBService : IInstarDDBService { - private readonly DynamoDBContext _ddbContext; + private readonly TimeProvider _timeProvider; + private readonly DynamoDBContext _ddbContext; + private readonly string _guildId; - public InstarDDBService(IConfiguration config) + public InstarDDBService(IConfiguration config, TimeProvider timeProvider) { - var region = config.GetSection("AWS").GetValue("Region"); + _timeProvider = timeProvider; + var region = config.GetSection("AWS").GetValue("Region"); + + _guildId = config.GetValue("TargetGuild") + ?? throw new ConfigurationException("TargetGuild is not set."); var client = new AmazonDynamoDBClient(new AWSIAMCredential(config), RegionEndpoint.GetBySystemName(region)); _ddbContext = new DynamoDBContextBuilder() @@ -30,7 +37,7 @@ public InstarDDBService(IConfiguration config) { try { - var result = await _ddbContext.LoadAsync(snowflake.ID.ToString()); + var result = await _ddbContext.LoadAsync(_guildId, snowflake.ID.ToString()); return result is null ? null : new InstarDatabaseEntry(_ddbContext, result); } catch (Exception ex) @@ -42,7 +49,7 @@ public InstarDDBService(IConfiguration config) public async Task> GetOrCreateUserAsync(IGuildUser user) { - var data = await _ddbContext.LoadAsync(user.Id.ToString()) ?? InstarUserData.CreateFrom(user); + var data = await _ddbContext.LoadAsync(_guildId, user.Id.ToString()) ?? InstarUserData.CreateFrom(user); return new InstarDatabaseEntry(_ddbContext, data); } @@ -51,13 +58,66 @@ public async Task>> GetBatchUsersAsync( { var batches = _ddbContext.CreateBatchGet(); foreach (var snowflake in snowflakes) - batches.AddKey(snowflake.ID.ToString()); + batches.AddKey(_guildId, snowflake.ID.ToString()); await _ddbContext.ExecuteBatchGetAsync(batches); return batches.Results.Select(x => new InstarDatabaseEntry(_ddbContext, x)).ToList(); } + public async Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness) + { + var birthday = new Birthday(birthdate, _timeProvider); + var birthdayUtc = birthday.Birthdate.ToUniversalTime(); + + + var start = birthdayUtc - fuzziness; + var end = birthdayUtc + fuzziness; + + // Build one or two ranges depending on whether we cross midnight + var ranges = new List<(string From, string To)>(); + + if (start.Date == end.Date) + ranges.Add((Utilities.ToBirthdateKey(start), Utilities.ToBirthdateKey(end))); + else + { + // Range 1: start -> end of start day + var endOfStartDay = new DateTime(start.Year, start.Month, start.Day, 23, 59, 59, DateTimeKind.Utc); + ranges.Add((Utilities.ToBirthdateKey(start), Utilities.ToBirthdateKey(endOfStartDay))); + + // Range 2: start of end day -> end + var startOfEndDay = new DateTime(end.Year, end.Month, end.Day, 0, 0, 0, DateTimeKind.Utc); + ranges.Add((Utilities.ToBirthdateKey(startOfEndDay), Utilities.ToBirthdateKey(end))); + } + + var results = new List>(); + + foreach (var range in ranges) + { + var config = new QueryOperationConfig + { + IndexName = "birthdate-gsi", + KeyExpression = new Expression + { + ExpressionStatement = "guild_id = :g AND birthdate BETWEEN :from AND :to", + ExpressionAttributeValues = new Dictionary + { + [":g"] = _guildId, + [":from"] = range.From, + [":to"] = range.To + } + } + }; + + var search = _ddbContext.FromQueryAsync(config); + + var page = await search.GetRemainingAsync().ConfigureAwait(false); + results.AddRange(page.Select(u => new InstarDatabaseEntry(_ddbContext, u))); + } + + return results; + } + public async Task CreateUserAsync(InstarUserData data) { await _ddbContext.SaveAsync(data); diff --git a/InstarBot/Snowflake.cs b/InstarBot/Snowflake.cs index d5be95a..ff4d120 100644 --- a/InstarBot/Snowflake.cs +++ b/InstarBot/Snowflake.cs @@ -14,10 +14,11 @@ namespace PaxAndromeda.Instar; /// /// /// Snowflakes are encoded in the following way: -/// +/// /// Timestamp Wrkr Prcs Increment /// 111111111111111111111111111111111111111111 11111 11111 111111111111 /// 64 22 17 12 0 +/// /// /// Timestamp is the milliseconds since Discord Epoch, the first second of 2015, or 1420070400000 /// Worker ID is the internal worker that generated the ID diff --git a/InstarBot/Strings.Designer.cs b/InstarBot/Strings.Designer.cs index 660fe50..d234ab7 100644 --- a/InstarBot/Strings.Designer.cs +++ b/InstarBot/Strings.Designer.cs @@ -60,6 +60,15 @@ internal Strings() { } } + /// + /// Looks up a localized string similar to :birthday: <@&{0}> {1}!. + /// + public static string Birthday_Announcement { + get { + return ResourceManager.GetString("Birthday_Announcement", resourceCulture); + } + } + /// /// Looks up a localized string similar to Error while attempting to withhold membership for <@{0}>: User is already a member.. /// @@ -456,6 +465,123 @@ public static string Command_ReportUser_ReportSent { } } + /// + /// Looks up a localized string similar to Your birthday has been reset. You may now set your birthday again using `/setbirthday` in the server.. + /// + public static string Command_ResetBirthday_EndUserNotification { + get { + return ResourceManager.GetString("Command_ResetBirthday_EndUserNotification", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Successfully reset the birthday of <@{0}>, but the birthday role could not be automatically removed. The user has been notified of the reset by DM.. + /// + public static string Command_ResetBirthday_Error_RemoveBirthdayRole { + get { + return ResourceManager.GetString("Command_ResetBirthday_Error_RemoveBirthdayRole", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not reset the birthday of <@{0}>: an unknown error has occurred. Please try again later.. + /// + public static string Command_ResetBirthday_Error_Unknown { + get { + return ResourceManager.GetString("Command_ResetBirthday_Error_Unknown", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not reset the birthday of user <@{0}>: user was not found in the database.. + /// + public static string Command_ResetBirthday_Error_UserNotFound { + get { + return ResourceManager.GetString("Command_ResetBirthday_Error_UserNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Successfully reset the birthday of <@{0}>. The user has been notified by DM.. + /// + public static string Command_ResetBirthday_Success { + get { + return ResourceManager.GetString("Command_ResetBirthday_Success", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to There are only {0} days in {1} {2}. Your birthday was not set.. + /// + public static string Command_SetBirthday_DaysInMonthOutOfRange { + get { + return ResourceManager.GetString("Command_SetBirthday_DaysInMonthOutOfRange", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You have already set your birthday on this server to <t:{0}:F>. If this birthday is in error, please contact staff to reset it for you.. + /// + public static string Command_SetBirthday_Error_AlreadySet { + get { + return ResourceManager.GetString("Command_SetBirthday_Error_AlreadySet", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your birthday could not be set at this time. Please try again later.. + /// + public static string Command_SetBirthday_Error_CouldNotSetBirthday { + get { + return ResourceManager.GetString("Command_SetBirthday_Error_CouldNotSetBirthday", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An unknown error has occurred. Instar developers have been notified.. + /// + public static string Command_SetBirthday_Error_Unknown { + get { + return ResourceManager.GetString("Command_SetBirthday_Error_Unknown", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to There are only 12 months in a year. Your birthday was not set.. + /// + public static string Command_SetBirthday_MonthsOutOfRange { + get { + return ResourceManager.GetString("Command_SetBirthday_MonthsOutOfRange", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are not a time traveler. Your birthday was not set.. + /// + public static string Command_SetBirthday_NotTimeTraveler { + get { + return ResourceManager.GetString("Command_SetBirthday_NotTimeTraveler", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your birthday was set to <t:{0}:F>.. + /// + public static string Command_SetBirthday_Success { + get { + return ResourceManager.GetString("Command_SetBirthday_Success", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to User entered the birthday <t:{0}:F> which indicates they are under the age of {1}.. + /// + public static string Command_SetBirthday_Underage_AMHReason { + get { + return ResourceManager.GetString("Command_SetBirthday_Underage_AMHReason", resourceCulture); + } + } + /// /// Looks up a localized string similar to Instar Auto Member System. /// @@ -465,6 +591,15 @@ public static string Embed_AMS_Footer { } } + /// + /// Looks up a localized string similar to Instar Birthday System. + /// + public static string Embed_BirthdaySystem_Footer { + get { + return ResourceManager.GetString("Embed_BirthdaySystem_Footer", resourceCulture); + } + } + /// /// Looks up a localized string similar to Instar Paging System. /// @@ -474,6 +609,26 @@ public static string Embed_Page_Footer { } } + /// + /// Looks up a localized string similar to <@{0}> has set their birthday to <t:{1}:F> which puts their age as {2} years old.. + /// + public static string Embed_UnderageUser_WarningTemplate_Member { + get { + return ResourceManager.GetString("Embed_UnderageUser_WarningTemplate_Member", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to <@{0}> has set their birthday to <t:{1}:F> which puts their age as {2} years old. + /// + ///As the user is a new member, their membership has automatically been withheld pending a staff review.. + /// + public static string Embed_UnderageUser_WarningTemplate_NewMember { + get { + return ResourceManager.GetString("Embed_UnderageUser_WarningTemplate_NewMember", resourceCulture); + } + } + /// /// Looks up a localized string similar to Instar Message Reporting System. /// @@ -491,5 +646,14 @@ public static string InstarLogoUrl { return ResourceManager.GetString("InstarLogoUrl", resourceCulture); } } + + /// + /// Looks up a localized string similar to and. + /// + public static string JoiningConjunction { + get { + return ResourceManager.GetString("JoiningConjunction", resourceCulture); + } + } } } diff --git a/InstarBot/Strings.resx b/InstarBot/Strings.resx index 70110a6..427b42b 100644 --- a/InstarBot/Strings.resx +++ b/InstarBot/Strings.resx @@ -284,4 +284,71 @@ Instar Message Reporting System + + :birthday: <@&{0}> {1}! + {0} is the ID of the Happy Birthday role, {1} is the list of recipients. + + + and + + + Instar Birthday System + + + <@{0}> has set their birthday to <t:{1}:F> which puts their age as {2} years old. + {0} is the user ID, {1} is the user birthday timestamp, and {2} is the user's current age in years. + + + <@{0}> has set their birthday to <t:{1}:F> which puts their age as {2} years old. + +As the user is a new member, their membership has automatically been withheld pending a staff review. + {0} is the user ID, {1} is the user birthday timestamp, and {2} is the user's current age in years. + + + You are not a time traveler. Your birthday was not set. + + + An unknown error has occurred. Instar developers have been notified. + + + There are only {0} days in {1} {2}. Your birthday was not set. + {0} is the number of days in the month {1} of year {2}. + + + There are only 12 months in a year. Your birthday was not set. + + + User entered the birthday <t:{0}:F> which indicates they are under the age of {1}. + {0} is the user birthday timestamp, {1} is the minimum permissible age. + + + Your birthday was set to <t:{0}:F>. + {0} is the user birthday timestamp + + + Your birthday could not be set at this time. Please try again later. + + + You have already set your birthday on this server to <t:{0}:F>. If this birthday is in error, please contact staff to reset it for you. + {0} is the timestamp of the user's birthday. + + + Could not reset the birthday of user <@{0}>: user was not found in the database. + {0] is the user ID. + + + Successfully reset the birthday of <@{0}>. The user has been notified by DM. + {0} is the user ID. + + + Your birthday has been reset. You may now set your birthday again using `/setbirthday` in the server. + + + Could not reset the birthday of <@{0}>: an unknown error has occurred. Please try again later. + {0} is the user ID + + + Successfully reset the birthday of <@{0}>, but the birthday role could not be automatically removed. The user has been notified of the reset by DM. + {0} is the user ID. + \ No newline at end of file diff --git a/InstarBot/Utilities.cs b/InstarBot/Utilities.cs index a1a0eb6..c18747e 100644 --- a/InstarBot/Utilities.cs +++ b/InstarBot/Utilities.cs @@ -1,121 +1,83 @@ -using System.Reflection; +using System.Globalization; +using System.Reflection; using System.Runtime.Serialization; namespace PaxAndromeda.Instar; -public static class EnumExtensions -{ - private static class EnumCache where T : Enum - { - public static readonly IReadOnlyDictionary Map = - typeof(T) - .GetFields(BindingFlags.Public | BindingFlags.Static) - .Select(f => ( - Field: f, - Value: f.GetCustomAttribute()?.Value ?? f.Name, - EnumValue: (T)f.GetValue(null)! - )) - .ToDictionary( - x => x.Value, - x => x.EnumValue, - StringComparer.OrdinalIgnoreCase); - } - - /// - /// Attempts to parse a string representation of an enum value or its associated - /// value to its corresponding enum value. - /// - /// The type of the enum to parse. - /// The string representation of the enum value or its associated - /// value. - /// When this method returns, contains the enum value if parsing succeeded; - /// otherwise, the default value of the enum. - /// - /// true if the string value was successfully parsed to an enum value; - /// otherwise, false. - /// - public static bool TryParseEnumMember(string value, out T result) where T : Enum - { - // NULLABILITY: result can be null if TryGetValue returns false - return EnumCache.Map.TryGetValue(value, out result!); - } - - /// - /// Attempts to parse a string representation of an enum value or its associated - /// value to its corresponding enum value. Throws an exception - /// if the specified value cannot be parsed. - /// - /// The type of the enum to parse. - /// The string representation of the enum value or its associated - /// value. - /// Returns the corresponding enum value of type . - /// Thrown when the specified value does not match any enum value - /// or associated value in the enum type . - public static T ParseEnumMember(string value) where T : Enum - { - return TryParseEnumMember(value, out T result) - ? result - : throw new ArgumentException($"Value '{value}' not found in enum {typeof(T).Name}"); - } - - /// - /// Retrieves the string representation of an enum value as defined by its associated - /// value, or the enum value's name if no attribute is present. - /// - /// The type of the enum. - /// The enum value to retrieve the string representation for. - /// - /// The string representation of the enum value as defined by the , - /// or the enum value's name if no attribute is present. - /// - public static string GetEnumMemberValue(this T value) where T : Enum - { - return EnumCache.Map - .FirstOrDefault(x => EqualityComparer.Default.Equals(x.Value, value)) - .Key ?? value.ToString(); - } -} - public static class Utilities { - /// - /// Retrieves a list of attributes of a specified type defined on an enum value . - /// - /// The type of the attribute to retrieve. - /// The enum value whose attributes are to be retrieved. - /// - /// A list of attributes of the specified type associated with the enum value; - /// or null if no attributes of the specified type are found. - /// - public static List? GetAttributesOfType(this Enum enumVal) where T : Attribute - { - var type = enumVal.GetType(); - var membersInfo = type.GetMember(enumVal.ToString()); - if (membersInfo.Length == 0) - return null; + private static class EnumCache where T : Enum + { + public static readonly IReadOnlyDictionary Map = + typeof(T) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(f => ( + Field: f, + Value: f.GetCustomAttribute()?.Value ?? f.Name, + EnumValue: (T)f.GetValue(null)! + )) + .ToDictionary( + x => x.Value, + x => x.EnumValue, + StringComparer.OrdinalIgnoreCase); + } - var attributes = membersInfo[0].GetCustomAttributes(typeof(T), false); - return attributes.Length > 0 ? attributes.OfType().ToList() : null; - } + extension(Enum enumVal) + { + /// + /// Retrieves a list of attributes of a specified type defined on an enum value . + /// + /// The type of the attribute to retrieve. + /// + /// A list of attributes of the specified type associated with the enum value; + /// or null if no attributes of the specified type are found. + /// + public List? GetAttributesOfType() where T : Attribute + { + var type = enumVal.GetType(); + var membersInfo = type.GetMember(enumVal.ToString()); + if (membersInfo.Length == 0) + return null; - /// - /// Retrieves the first custom attribute of the specified type applied to the - /// member that corresponds to the given enum value . - /// - /// The type of attribute to retrieve. - /// The enum value whose member's custom attribute is retrieved. - /// - /// The first custom attribute of type if found; - /// otherwise, null. - /// - public static T? GetAttributeOfType(this Enum enumVal) where T : Attribute - { - var type = enumVal.GetType(); - var membersInfo = type.GetMember(enumVal.ToString()); - return membersInfo.Length == 0 ? null : membersInfo[0].GetCustomAttribute(false); - } + var attributes = membersInfo[0].GetCustomAttributes(typeof(T), false); + return attributes.Length > 0 ? attributes.OfType().ToList() : null; + } + + /// + /// Retrieves the first custom attribute of the specified type applied to the + /// member that corresponds to the given enum value . + /// + /// The type of attribute to retrieve. + /// + /// The first custom attribute of type if found; + /// otherwise, null. + /// + public T? GetAttributeOfType() where T : Attribute + { + var type = enumVal.GetType(); + var membersInfo = type.GetMember(enumVal.ToString()); + return membersInfo.Length == 0 ? null : membersInfo[0].GetCustomAttribute(false); + } + + /// + /// Retrieves the string representation of an enum value as defined by its associated + /// value, or the enum value's name if no attribute is present. + /// + /// The type of the enum. + /// The enum value to retrieve the string representation for. + /// + /// The string representation of the enum value as defined by the , + /// or the enum value's name if no attribute is present. + /// + public static string GetEnumMemberValue(T value) where T : Enum + { + return EnumCache.Map + .FirstOrDefault(x => EqualityComparer.Default.Equals(x.Value, value)) + .Key ?? value.ToString(); + } + } - /// + /// /// Converts the string representation of an enum value or its associated /// value to its corresponding enum value of type . /// @@ -127,19 +89,67 @@ public static class Utilities /// enum value or associated value in the enum type . public static T ToEnum(string name) where T : Enum { - return EnumExtensions.ParseEnumMember(name); + return ParseEnumMember(name); } - /// - /// Converts a string in SCREAMING_SNAKE_CASE format to PascalCase format. - /// - /// The input string in SCREAMING_SNAKE_CASE format. - /// - /// A string converted from SCREAMING_SNAKE_CASE to PascalCase format. - /// - public static string ScreamingToPascalCase(string input) + /// + /// Attempts to parse a string representation of an enum value or its associated + /// value to its corresponding enum value. + /// + /// The type of the enum to parse. + /// The string representation of the enum value or its associated + /// value. + /// When this method returns, contains the enum value if parsing succeeded; + /// otherwise, the default value of the enum. + /// + /// true if the string value was successfully parsed to an enum value; + /// otherwise, false. + /// + public static bool TryParseEnumMember(string value, out T result) where T : Enum + { + // NULLABILITY: result can be null if TryGetValue returns false + return EnumCache.Map.TryGetValue(value, out result!); + } + + /// + /// Attempts to parse a string representation of an enum value or its associated + /// value to its corresponding enum value. Throws an exception + /// if the specified value cannot be parsed. + /// + /// The type of the enum to parse. + /// The string representation of the enum value or its associated + /// value. + /// Returns the corresponding enum value of type . + /// Thrown when the specified value does not match any enum value + /// or associated value in the enum type . + public static T ParseEnumMember(string value) where T : Enum + { + return TryParseEnumMember(value, out T result) + ? result + : throw new ArgumentException($"Value '{value}' not found in enum {typeof(T).Name}"); + } + + /// + /// Converts a string in SCREAMING_SNAKE_CASE format to PascalCase format. + /// + /// The input string in SCREAMING_SNAKE_CASE format. + /// + /// A string converted from SCREAMING_SNAKE_CASE to PascalCase format. + /// + public static string ScreamingToPascalCase(string input) { // COMMUNITY_MANAGER ⇒ CommunityManager return input.Split('_').Select(piece => piece[0] + piece[1..].ToLower()).Aggregate((a, b) => a + b); } + + /// + /// Generates a string key representing the specified Birthdate in UTC, formatted as month, day, hour, and minute. + /// + /// This method is useful for creating compact, sortable keys based on Birthdate and time. The returned + /// string uses UTC time to ensure consistency across time zones. + /// The Birthdate to convert to a key. The value is interpreted as a local or unspecified time and converted to UTC + /// before formatting. + /// A string containing the UTC month, day, hour, and minute of the Birthdate in the format "MMddHHmm". + public static string ToBirthdateKey(DateTimeOffset dt) => + dt.ToUniversalTime().ToString("MMddHHmm", CultureInfo.InvariantCulture); } \ No newline at end of file From f9d824b33076b76678a3753931bc1e639cb74e17 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Wed, 17 Dec 2025 20:40:32 -0800 Subject: [PATCH 10/53] Introduced new testing framework to make tests more predictable and easier to implement. --- .gitignore | 3 +- .../InstarBot.Tests.Common.csproj | 4 + InstarBot.Tests.Common/MetaTests.cs | 2 +- .../MockInstarDDBServiceTests.cs | 45 --- InstarBot.Tests.Common/Models/TestChannel.cs | 276 ------------- InstarBot.Tests.Common/Models/TestGuild.cs | 31 -- InstarBot.Tests.Common/Models/TestMessage.cs | 152 -------- .../Models/UserDatabaseInformation.cs | 10 - .../Services/MockAutoMemberSystem.cs | 24 -- .../Services/MockDiscordService.cs | 124 ------ .../Services/MockDynamicConfigService.cs | 43 --- .../Services/MockGaiusAPIService.cs | 57 --- .../Services/MockInstarDDBService.cs | 142 ------- InstarBot.Tests.Common/TestContext.cs | 67 ---- InstarBot.Tests.Common/TestUtilities.cs | 345 ----------------- .../InstarBot.Tests.Integration.csproj | 1 + .../AutoMemberSystemCommandTests.cs | 117 +++--- .../CheckEligibilityCommandTests.cs | 215 +++++------ .../Interactions/PageCommandTests.cs | 158 ++++---- .../Interactions/PingCommandTests.cs | 13 +- .../Interactions/ReportUserTests.cs | 118 ++---- .../Interactions/ResetBirthdayCommandTests.cs | 99 +++-- .../Interactions/SetBirthdayCommandTests.cs | 186 ++++----- .../Services/AutoMemberSystemTests.cs | 364 +++++++++--------- .../Services/BirthdaySystemTests.cs | 238 ++++-------- InstarBot.Tests.Integration/TestTest.cs | 49 +++ InstarBot.Tests.Orchestrator/IMockOf.cs | 15 + .../InstarBot.Test.Framework.csproj | 13 + .../MockExtensions.cs | 100 +++++ .../Models/TestChannel.cs | 339 ++++++++++++++++ .../Models/TestGuild.cs | 42 ++ .../Models/TestGuildUser.cs | 146 +++---- .../Models/TestInteractionContext.cs | 71 ++++ .../Models/TestMessage.cs | 155 ++++++++ .../Models/TestRole.cs | 32 +- .../Models/TestSocketUser.cs | 84 ++++ .../Models/TestUser.cs | 25 +- .../Services/TestAutoMemberSystem.cs | 27 ++ .../Services/TestBirthdaySystem.cs | 23 ++ .../Services/TestDatabaseService.cs | 131 +++++++ .../Services/TestDiscordService.cs | 143 +++++++ .../Services/TestDynamicConfigService.cs | 43 +++ .../Services/TestGaiusAPIService.cs | 95 +++++ .../Services/TestMetricService.cs | 10 +- .../TestDatabaseContextBuilder.cs | 41 ++ .../TestDiscordContextBuilder.cs | 134 +++++++ .../TestOrchestrator.cs | 226 +++++++++++ .../TestServiceProviderBuilder.cs | 132 +++++++ .../TestTimeProvider.cs | 28 ++ .../InstarBot.Tests.Unit.csproj | 1 + .../RequireStaffMemberAttributeTests.cs | 51 +-- InstarBot.Tests.Unit/SnowflakeTests.cs | 5 +- InstarBot.sln | 21 +- InstarBot/AppContext.cs | 3 +- InstarBot/Commands/AutoMemberHoldCommand.cs | 6 +- InstarBot/Commands/CheckEligibilityCommand.cs | 4 +- InstarBot/Commands/PageCommand.cs | 4 +- InstarBot/Commands/ResetBirthdayCommand.cs | 4 +- InstarBot/Commands/SetBirthdayCommand.cs | 8 +- InstarBot/DynamoModels/InstarDatabaseEntry.cs | 9 +- InstarBot/DynamoModels/InstarUserData.cs | 4 +- InstarBot/DynamoModels/Notification.cs | 70 ++++ .../Embeds/InstarCheckEligibilityEmbed.cs | 4 +- InstarBot/Embeds/InstarEligibilityEmbed.cs | 6 +- InstarBot/IBuilder.cs | 6 + InstarBot/InstarBot.csproj | 1 + InstarBot/Metrics/Metric.cs | 7 + InstarBot/Program.cs | 18 +- InstarBot/Services/AWSDynamicConfigService.cs | 2 +- InstarBot/Services/AutoMemberSystem.cs | 56 +-- InstarBot/Services/BirthdaySystem.cs | 54 +-- InstarBot/Services/CloudwatchMetricService.cs | 37 +- InstarBot/Services/DiscordService.cs | 4 +- InstarBot/Services/FileSystemMetricService.cs | 7 + InstarBot/Services/GaiusAPIService.cs | 2 +- InstarBot/Services/IAutoMemberSystem.cs | 6 +- InstarBot/Services/IBirthdaySystem.cs | 5 +- ...nstarDDBService.cs => IDatabaseService.cs} | 42 +- InstarBot/Services/IMetricService.cs | 3 +- InstarBot/Services/IRunnableService.cs | 9 + InstarBot/Services/IScheduledService.cs | 5 + InstarBot/Services/IStartableService.cs | 9 + ...DDBService.cs => InstarDynamoDBService.cs} | 55 ++- InstarBot/Services/NotificationService.cs | 50 +++ InstarBot/Services/ScheduledService.cs | 160 ++++++++ InstarBot/Snowflake.cs | 2 +- InstarBot/Utilities.cs | 4 +- 87 files changed, 3235 insertions(+), 2447 deletions(-) delete mode 100644 InstarBot.Tests.Common/MockInstarDDBServiceTests.cs delete mode 100644 InstarBot.Tests.Common/Models/TestChannel.cs delete mode 100644 InstarBot.Tests.Common/Models/TestGuild.cs delete mode 100644 InstarBot.Tests.Common/Models/TestMessage.cs delete mode 100644 InstarBot.Tests.Common/Models/UserDatabaseInformation.cs delete mode 100644 InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs delete mode 100644 InstarBot.Tests.Common/Services/MockDiscordService.cs delete mode 100644 InstarBot.Tests.Common/Services/MockDynamicConfigService.cs delete mode 100644 InstarBot.Tests.Common/Services/MockGaiusAPIService.cs delete mode 100644 InstarBot.Tests.Common/Services/MockInstarDDBService.cs delete mode 100644 InstarBot.Tests.Common/TestContext.cs create mode 100644 InstarBot.Tests.Integration/TestTest.cs create mode 100644 InstarBot.Tests.Orchestrator/IMockOf.cs create mode 100644 InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj create mode 100644 InstarBot.Tests.Orchestrator/MockExtensions.cs create mode 100644 InstarBot.Tests.Orchestrator/Models/TestChannel.cs create mode 100644 InstarBot.Tests.Orchestrator/Models/TestGuild.cs rename {InstarBot.Tests.Common => InstarBot.Tests.Orchestrator}/Models/TestGuildUser.cs (53%) create mode 100644 InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs create mode 100644 InstarBot.Tests.Orchestrator/Models/TestMessage.cs rename {InstarBot.Tests.Common => InstarBot.Tests.Orchestrator}/Models/TestRole.cs (63%) create mode 100644 InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs rename {InstarBot.Tests.Common => InstarBot.Tests.Orchestrator}/Models/TestUser.cs (77%) create mode 100644 InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs create mode 100644 InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs create mode 100644 InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs create mode 100644 InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs create mode 100644 InstarBot.Tests.Orchestrator/Services/TestDynamicConfigService.cs create mode 100644 InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs rename InstarBot.Tests.Common/Services/MockMetricService.cs => InstarBot.Tests.Orchestrator/Services/TestMetricService.cs (67%) create mode 100644 InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs create mode 100644 InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs create mode 100644 InstarBot.Tests.Orchestrator/TestOrchestrator.cs create mode 100644 InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs create mode 100644 InstarBot.Tests.Orchestrator/TestTimeProvider.cs create mode 100644 InstarBot/DynamoModels/Notification.cs create mode 100644 InstarBot/IBuilder.cs rename InstarBot/Services/{IInstarDDBService.cs => IDatabaseService.cs} (65%) create mode 100644 InstarBot/Services/IRunnableService.cs create mode 100644 InstarBot/Services/IScheduledService.cs create mode 100644 InstarBot/Services/IStartableService.cs rename InstarBot/Services/{InstarDDBService.cs => InstarDynamoDBService.cs} (70%) create mode 100644 InstarBot/Services/NotificationService.cs create mode 100644 InstarBot/Services/ScheduledService.cs diff --git a/.gitignore b/.gitignore index b63c471..d4aa06e 100644 --- a/.gitignore +++ b/.gitignore @@ -368,4 +368,5 @@ FodyWeavers.xsd .idea/ # Specflow - Autogenerated files -*.feature.cs \ No newline at end of file +*.feature.cs +/qodana.yaml diff --git a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj index a57be9c..6e8fa45 100644 --- a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj +++ b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj @@ -17,6 +17,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/InstarBot.Tests.Common/MetaTests.cs b/InstarBot.Tests.Common/MetaTests.cs index 6a30743..a032209 100644 --- a/InstarBot.Tests.Common/MetaTests.cs +++ b/InstarBot.Tests.Common/MetaTests.cs @@ -3,7 +3,7 @@ namespace InstarBot.Tests; -public class MetaTests +public static class MetaTests { [Fact] public static void MatchesFormat_WithValidText_ShouldReturnTrue() diff --git a/InstarBot.Tests.Common/MockInstarDDBServiceTests.cs b/InstarBot.Tests.Common/MockInstarDDBServiceTests.cs deleted file mode 100644 index a93b704..0000000 --- a/InstarBot.Tests.Common/MockInstarDDBServiceTests.cs +++ /dev/null @@ -1,45 +0,0 @@ -using FluentAssertions; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.DynamoModels; -using Xunit; - -namespace InstarBot.Tests; - -public class MockInstarDDBServiceTests -{ - [Fact] - public static async Task UpdateMember_ShouldPersist() - { - // Arrange - var mockDDB = new MockInstarDDBService(); - var userId = Snowflake.Generate(); - - var user = new TestGuildUser - { - Id = userId, - Username = "username", - JoinedAt = DateTimeOffset.UtcNow - }; - - await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(user)); - - // Act - var retrievedUserEntry = await mockDDB.GetUserAsync(userId); - retrievedUserEntry.Should().NotBeNull(); - retrievedUserEntry.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord - { - Date = DateTime.UtcNow, - ModeratorID = Snowflake.Generate(), - Reason = "test reason" - }; - await retrievedUserEntry.UpdateAsync(); - - // Assert - var newlyRetrievedUserEntry = await mockDDB.GetUserAsync(userId); - - newlyRetrievedUserEntry.Should().NotBeNull(); - newlyRetrievedUserEntry.Data.AutoMemberHoldRecord.Should().NotBeNull(); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestChannel.cs b/InstarBot.Tests.Common/Models/TestChannel.cs deleted file mode 100644 index 67158ef..0000000 --- a/InstarBot.Tests.Common/Models/TestChannel.cs +++ /dev/null @@ -1,276 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Discord; -using PaxAndromeda.Instar; -using MessageProperties = Discord.MessageProperties; - -#pragma warning disable CS1998 -#pragma warning disable CS8625 - -namespace InstarBot.Tests.Models; - -[SuppressMessage("ReSharper", "ReplaceAutoPropertyWithComputedProperty")] -public sealed class TestChannel(Snowflake id) : ITextChannel -{ - public ulong Id { get; } = id; - public DateTimeOffset CreatedAt { get; } = id.Time; - - public Task ModifyAsync(Action func, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public OverwritePermissions? GetPermissionOverwrite(IRole role) - { - return null; - } - - public OverwritePermissions? GetPermissionOverwrite(IUser user) - { - return null; - } - - public Task RemovePermissionOverwriteAsync(IRole role, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task RemovePermissionOverwriteAsync(IUser user, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task AddPermissionOverwriteAsync(IRole role, OverwritePermissions permissions, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task AddPermissionOverwriteAsync(IUser user, OverwritePermissions permissions, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public IAsyncEnumerable> GetUsersAsync(CacheMode mode = CacheMode.AllowDownload, - RequestOptions options = null) - { - throw new NotImplementedException(); - } - - Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) - { - throw new NotImplementedException(); - } - - public int Position { get; } = 0; - public ChannelFlags Flags { get; } = default!; - public IGuild Guild { get; } = null!; - public ulong GuildId { get; } = 0; - public IReadOnlyCollection PermissionOverwrites { get; } = null!; - - IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) - { - return GetUsersAsync(mode, options); - } - - Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) - { - throw new NotImplementedException(); - } - - public string Name { get; } = null!; - - public Task GetMessageAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, - RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public async IAsyncEnumerable> GetMessagesAsync(int limit = 100, - CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) - { - yield return _messages; - } - - public async IAsyncEnumerable> GetMessagesAsync(ulong fromMessageId, Direction dir, - int limit = 100, CacheMode mode = CacheMode.AllowDownload, - RequestOptions options = null) - { - yield break; - } - - public async IAsyncEnumerable> GetMessagesAsync(IMessage fromMessage, Direction dir, - int limit = 100, CacheMode mode = CacheMode.AllowDownload, - RequestOptions options = null) - { - yield break; - } - - public Task> GetPinnedMessagesAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task DeleteMessageAsync(IMessage message, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task ModifyMessageAsync(ulong messageId, Action func, - RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task TriggerTypingAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public IDisposable EnterTypingState(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public string Mention { get; } = null!; - - public Task DeleteAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task GetCategoryAsync(CacheMode mode = CacheMode.AllowDownload, - RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task SyncPermissionsAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task CreateInviteAsync(int? maxAge, int? maxUses = null, bool isTemporary = false, - bool isUnique = false, - RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge, int? maxUses = null, - bool isTemporary = false, - bool isUnique = false, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge, - int? maxUses = null, - bool isTemporary = false, bool isUnique = false, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = null, - bool isTemporary = false, - bool isUnique = false, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task> GetInvitesAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public ulong? CategoryId { get; } = null; - - public Task CreateWebhookAsync(string name, Stream avatar = null, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task GetWebhookAsync(ulong id, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task> GetWebhooksAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task DeleteMessagesAsync(IEnumerable messages, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task DeleteMessagesAsync(IEnumerable messageIds, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task ModifyAsync(Action func, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, - ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, - IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task> GetActiveThreadsAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public bool IsNsfw { get; } = false; - public string Topic { get; } = null!; - public int SlowModeInterval { get; } = 0; - public ThreadArchiveDuration DefaultArchiveDuration { get; } = default!; - - public int DefaultSlowModeInterval => throw new NotImplementedException(); - - public ChannelType ChannelType => throw new NotImplementedException(); - - private readonly List _messages = []; - - public void AddMessage(IGuildUser user, string message) - { - _messages.Add(new TestMessage(user, message)); - } - - public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) - { - var msg = new TestMessage(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags, poll); - _messages.Add(msg); - - return Task.FromResult(msg as IUserMessage); - } - - public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) - { - throw new NotImplementedException(); - } - - public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) - { - throw new NotImplementedException(); - } - - public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) - { - throw new NotImplementedException(); - } - - public Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestGuild.cs b/InstarBot.Tests.Common/Models/TestGuild.cs deleted file mode 100644 index 6217a51..0000000 --- a/InstarBot.Tests.Common/Models/TestGuild.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Discord; -using PaxAndromeda.Instar; - -namespace InstarBot.Tests.Models; - -// ReSharper disable once ClassWithVirtualMembersNeverInherited.Global -public class TestGuild : IInstarGuild -{ - public ulong Id { get; init; } - public IEnumerable TextChannels { get; init; } = [ ]; - - - public IEnumerable Roles { get; init; } = null!; - - public List Users { get; init; } = []; - - public virtual ITextChannel GetTextChannel(ulong channelId) - { - return TextChannels.First(n => n.Id.Equals(channelId)); - } - - public virtual IRole GetRole(Snowflake roleId) - { - return Roles.First(n => n.Id.Equals(roleId)); - } - - public void AddUser(TestGuildUser user) - { - Users.Add(user); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestMessage.cs b/InstarBot.Tests.Common/Models/TestMessage.cs deleted file mode 100644 index ff675d6..0000000 --- a/InstarBot.Tests.Common/Models/TestMessage.cs +++ /dev/null @@ -1,152 +0,0 @@ -using Discord; -using PaxAndromeda.Instar; -using MessageProperties = Discord.MessageProperties; - -namespace InstarBot.Tests.Models; - -public sealed class TestMessage : IUserMessage, IMessage -{ - - internal TestMessage(IUser user, string message) - { - Id = Snowflake.Generate(); - CreatedAt = DateTimeOffset.Now; - Timestamp = DateTimeOffset.Now; - Author = user; - - Content = message; - } - - public TestMessage(string text, bool isTTS, Embed? embed, RequestOptions? options, AllowedMentions? allowedMentions, MessageReference? messageReference, MessageComponent? components, ISticker[]? stickers, Embed[]? embeds, MessageFlags? flags, PollProperties? poll) - { - Content = text; - IsTTS = isTTS; - Flags= flags; - - var embedList = new List(); - - if (embed is not null) - embedList.Add(embed); - if (embeds is not null) - embedList.AddRange(embeds); - - Flags = flags; - Reference = messageReference; - } - - public ulong Id { get; } - public DateTimeOffset CreatedAt { get; } - - public Task DeleteAsync(RequestOptions options = null!) - { - throw new NotImplementedException(); - } - - public Task AddReactionAsync(IEmote emote, RequestOptions options = null!) - { - throw new NotImplementedException(); - } - - public Task RemoveReactionAsync(IEmote emote, IUser user, RequestOptions options = null!) - { - throw new NotImplementedException(); - } - - public Task RemoveReactionAsync(IEmote emote, ulong userId, RequestOptions options = null!) - { - throw new NotImplementedException(); - } - - public Task RemoveAllReactionsAsync(RequestOptions options = null!) - { - throw new NotImplementedException(); - } - - public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions options = null!) - { - throw new NotImplementedException(); - } - - public IAsyncEnumerable> GetReactionUsersAsync(IEmote emoji, int limit, RequestOptions? options = null, ReactionType type = ReactionType.Normal) - { - throw new NotImplementedException(); - } - - public MessageType Type => default; - public MessageSource Source => default; - public bool IsTTS { get; set; } - - public bool IsPinned => false; - public bool IsSuppressed => false; - public bool MentionedEveryone => false; - public string Content { get; } - public string CleanContent => null!; - public DateTimeOffset Timestamp { get; } - public DateTimeOffset? EditedTimestamp => null; - public IMessageChannel Channel => null!; - public IUser Author { get; } - public IThreadChannel Thread => null!; - public IReadOnlyCollection Attachments => null!; - public IReadOnlyCollection Embeds => null!; - public IReadOnlyCollection Tags => null!; - public IReadOnlyCollection MentionedChannelIds => null!; - public IReadOnlyCollection MentionedRoleIds => null!; - public IReadOnlyCollection MentionedUserIds => null!; - public MessageActivity Activity => null!; - public MessageApplication Application => null!; - public MessageReference Reference { get; set; } - - public IReadOnlyDictionary Reactions => null!; - public IReadOnlyCollection Components => null!; - public IReadOnlyCollection Stickers => null!; - public MessageFlags? Flags { get; set; } - - public IMessageInteraction Interaction => null!; - public MessageRoleSubscriptionData RoleSubscriptionData => null!; - - public PurchaseNotification PurchaseNotification => throw new NotImplementedException(); - - public MessageCallData? CallData => throw new NotImplementedException(); - public Task ModifyAsync(Action func, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task PinAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task UnpinAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task CrosspostAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public string Resolve(TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name, TagHandling roleHandling = TagHandling.Name, - TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name) - { - throw new NotImplementedException(); - } - - public Task EndPollAsync(RequestOptions options) - { - throw new NotImplementedException(); - } - - public IAsyncEnumerable> GetPollAnswerVotersAsync(uint answerId, int? limit = null, ulong? afterId = null, - RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public MessageResolvedData ResolvedData { get; set; } - public IUserMessage ReferencedMessage { get; set; } - public IMessageInteractionMetadata InteractionMetadata { get; set; } - public IReadOnlyCollection ForwardedMessages { get; set; } - public Poll? Poll { get; set; } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/UserDatabaseInformation.cs b/InstarBot.Tests.Common/Models/UserDatabaseInformation.cs deleted file mode 100644 index c0f9baa..0000000 --- a/InstarBot.Tests.Common/Models/UserDatabaseInformation.cs +++ /dev/null @@ -1,10 +0,0 @@ -using PaxAndromeda.Instar; - -namespace InstarBot.Tests.Models; - -public sealed record UserDatabaseInformation(Snowflake Snowflake) -{ - public DateTime Birthday { get; set; } - public DateTime JoinDate { get; set; } - public bool GrantedMembership { get; set; } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs b/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs deleted file mode 100644 index b52aade..0000000 --- a/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Discord; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.ConfigModels; -using PaxAndromeda.Instar.Services; - -namespace InstarBot.Tests.Services; - -public class MockAutoMemberSystem : IAutoMemberSystem -{ - public Task RunAsync() - { - throw new NotImplementedException(); - } - - public virtual MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IGuildUser user) - { - throw new NotImplementedException(); - } - - public Task Initialize() - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockDiscordService.cs b/InstarBot.Tests.Common/Services/MockDiscordService.cs deleted file mode 100644 index 96ad258..0000000 --- a/InstarBot.Tests.Common/Services/MockDiscordService.cs +++ /dev/null @@ -1,124 +0,0 @@ -using Discord; -using InstarBot.Tests.Models; -using JetBrains.Annotations; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Modals; -using PaxAndromeda.Instar.Services; - -namespace InstarBot.Tests.Services; - -public sealed class MockDiscordService : IDiscordService -{ - private IInstarGuild _guild; - private readonly AsyncEvent _userJoinedEvent = new(); - private readonly AsyncEvent _userLeftEvent = new(); - private readonly AsyncEvent _userUpdatedEvent = new(); - private readonly AsyncEvent _messageReceivedEvent = new(); - private readonly AsyncEvent _messageDeletedEvent = new(); - - public event Func UserJoined - { - add => _userJoinedEvent.Add(value); - remove => _userJoinedEvent.Remove(value); - } - - public event Func UserLeft - { - add => _userLeftEvent.Add(value); - remove => _userLeftEvent.Remove(value); - } - - public event Func UserUpdated - { - add => _userUpdatedEvent.Add(value); - remove => _userUpdatedEvent.Remove(value); - } - - public event Func MessageReceived - { - add => _messageReceivedEvent.Add(value); - remove => _messageReceivedEvent.Remove(value); - } - - public event Func MessageDeleted - { - add => _messageDeletedEvent.Add(value); - remove => _messageDeletedEvent.Remove(value); - } - - internal MockDiscordService(IInstarGuild guild) - { - _guild = guild; - } - - public IInstarGuild Guild - { - get => _guild; - set => _guild = value; - } - - public Task Start(IServiceProvider provider) - { - return Task.CompletedTask; - } - - public IInstarGuild GetGuild() - { - return _guild; - } - - public Task> GetAllUsers() - { - return Task.FromResult(((TestGuild)_guild).Users.AsEnumerable()); - } - - public Task GetChannel(Snowflake channelId) - { - return Task.FromResult(_guild.GetTextChannel(channelId)); - } - - public async IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime) - { - foreach (var channel in guild.TextChannels) - await foreach (var messageList in channel.GetMessagesAsync()) - foreach (var message in messageList) - yield return message; - } - - public IGuildUser? GetUser(Snowflake snowflake) - { - return ((TestGuild) _guild).Users.FirstOrDefault(n => n.Id.Equals(snowflake.ID)); - } - - public IEnumerable GetAllUsersWithRole(Snowflake roleId) - { - return ((TestGuild) _guild).Users.Where(n => n.RoleIds.Contains(roleId.ID)); - } - - public Task SyncUsers() - { - return Task.CompletedTask; - } - - public async Task TriggerUserJoined(IGuildUser user) - { - await _userJoinedEvent.Invoke(user); - } - - public async Task TriggerUserUpdated(UserUpdatedEventArgs args) - { - await _userUpdatedEvent.Invoke(args); - } - - [UsedImplicitly] - public async Task TriggerMessageReceived(IMessage message) - { - await _messageReceivedEvent.Invoke(message); - } - - public void AddUser(TestGuildUser user) - { - var guild = _guild as TestGuild; - guild?.AddUser(user); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockDynamicConfigService.cs b/InstarBot.Tests.Common/Services/MockDynamicConfigService.cs deleted file mode 100644 index 5eaed9c..0000000 --- a/InstarBot.Tests.Common/Services/MockDynamicConfigService.cs +++ /dev/null @@ -1,43 +0,0 @@ -using JetBrains.Annotations; -using Newtonsoft.Json; -using PaxAndromeda.Instar.ConfigModels; -using PaxAndromeda.Instar.Services; - -namespace InstarBot.Tests.Services; - -public sealed class MockDynamicConfigService : IDynamicConfigService -{ - private readonly string _configPath; - private readonly Dictionary _parameters = new(); - private InstarDynamicConfiguration _config = null!; - - public MockDynamicConfigService(string configPath) - { - _configPath = configPath; - - Task.Run(Initialize).Wait(); - } - - [UsedImplicitly] - public MockDynamicConfigService(string configPath, Dictionary parameters) - : this(configPath) - { - _parameters = parameters; - } - - public Task GetConfig() - { - return Task.FromResult(_config); - } - - public Task GetParameter(string parameterName) - { - return Task.FromResult(_parameters[parameterName])!; - } - - public async Task Initialize() - { - var data = await File.ReadAllTextAsync(_configPath); - _config = JsonConvert.DeserializeObject(data)!; - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockGaiusAPIService.cs b/InstarBot.Tests.Common/Services/MockGaiusAPIService.cs deleted file mode 100644 index 6ebc20a..0000000 --- a/InstarBot.Tests.Common/Services/MockGaiusAPIService.cs +++ /dev/null @@ -1,57 +0,0 @@ -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Gaius; -using PaxAndromeda.Instar.Services; - -namespace InstarBot.Tests.Services; - -public sealed class MockGaiusAPIService( - Dictionary> warnings, - Dictionary> caselogs, - bool inhibit = false) - : IGaiusAPIService -{ - public void Dispose() - { - // do nothing - } - - public Task> GetAllWarnings() - { - return Task.FromResult>(warnings.Values.SelectMany(list => list).ToList()); - } - - public Task> GetAllCaselogs() - { - return Task.FromResult>(caselogs.Values.SelectMany(list => list).ToList()); - } - - public Task> GetWarningsAfter(DateTime dt) - { - return Task.FromResult>(from list in warnings.Values from item in list where item.WarnDate > dt select item); - } - - public Task> GetCaselogsAfter(DateTime dt) - { - return Task.FromResult>(from list in caselogs.Values from item in list where item.Date > dt select item); - } - - public Task?> GetWarnings(Snowflake userId) - { - if (inhibit) - return Task.FromResult?>(null); - - return !warnings.TryGetValue(userId, out var warning) - ? Task.FromResult?>([]) - : Task.FromResult?>(warning); - } - - public Task?> GetCaselogs(Snowflake userId) - { - if (inhibit) - return Task.FromResult?>(null); - - return !caselogs.TryGetValue(userId, out var caselog) - ? Task.FromResult?>([]) - : Task.FromResult?>(caselog); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs deleted file mode 100644 index 0aa0446..0000000 --- a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Linq.Expressions; -using Amazon.DynamoDBv2.DataModel; -using Discord; -using Moq; -using Moq.Language.Flow; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.DynamoModels; -using PaxAndromeda.Instar.Services; - -namespace InstarBot.Tests.Services; - -/// -/// A mock implementation of the IInstarDDBService interface for unit testing purposes. -/// This class just uses Moq in the background to provide mockable behavior. -/// -/// -/// Implementation warning: MockInstarDDBService differs from the actual implementation of -/// InstarDDBService. All returned items from are references, -/// meaning any data set on them will persist for future calls. This is different from the -/// concrete implementation, in which you would need to call to -/// persist changes. -/// -public class MockInstarDDBService : IInstarDDBService -{ - private readonly Mock _ddbContextMock = new (); - private readonly Mock _internalMock = new (); - - public MockInstarDDBService() - { - _internalMock.Setup(n => n.GetUserAsync(It.IsAny())) - .ReturnsAsync((InstarDatabaseEntry?) null); - } - - public MockInstarDDBService(IEnumerable preload) - : this() - { - foreach (var data in preload) { - - _internalMock - .Setup(n => n.GetUserAsync(data.UserID!)) - .ReturnsAsync(new InstarDatabaseEntry(_ddbContextMock.Object, data)); - } - } - - public void Register(InstarUserData data) - { - // Quick check to make sure we're not overriding an existing exception - try - { - _internalMock.Object.GetUserAsync(Snowflake.Generate()); - } catch - { - return; - } - - _internalMock - .Setup(n => n.GetUserAsync(data.UserID!)) - .ReturnsAsync(new InstarDatabaseEntry(_ddbContextMock.Object, data)); - } - - public Task?> GetUserAsync(Snowflake snowflake) - { - return _internalMock.Object.GetUserAsync(snowflake); - } - - public async Task> GetOrCreateUserAsync(IGuildUser user) - { - // We can't directly set up this method for mocking due to the custom logic here. - // To work around this, we'll first call the same method on the internal mock. If - // it returns a value, we return that. - var mockedResult = await _internalMock.Object.GetOrCreateUserAsync(user); - - // .GetOrCreateUserAsync is expected to never return null in production. However, - // with mocks, it CAN return null if the method was not set up. - // - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - if (mockedResult is not null) - return mockedResult; - - var result = await _internalMock.Object.GetUserAsync(user.Id); - - if (result is not null) - return result; - - await CreateUserAsync(InstarUserData.CreateFrom(user)); - result = await _internalMock.Object.GetUserAsync(user.Id); - - if (result is null) - Assert.Fail("Failed to correctly set up mocks in MockInstarDDBService"); - - return result; - } - - public async Task>> GetBatchUsersAsync(IEnumerable snowflakes) - { - return await GetLocalUsersAsync(snowflakes).ToListAsync(); - } - - private async IAsyncEnumerable> GetLocalUsersAsync(IEnumerable snowflakes) - { - foreach (var snowflake in snowflakes) - { - var data = await _internalMock.Object.GetUserAsync(snowflake); - - if (data is not null) - yield return data; - } - } - - public Task CreateUserAsync(InstarUserData data) - { - _internalMock - .Setup(n => n.GetUserAsync(data.UserID!)) - .ReturnsAsync(new InstarDatabaseEntry(_ddbContextMock.Object, data)); - - return Task.CompletedTask; - } - - public async Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness) - { - var result = await _internalMock.Object.GetUsersByBirthday(birthdate, fuzziness); - - return result; - } - - /// - /// Configures a setup for the specified expression on the mocked interface, allowing - /// control over the behavior of the mock for the given member. - /// - /// Use this method to define expectations or return values for specific members of when using mocking frameworks. The returned setup object allows chaining of additional - /// configuration methods, such as specifying return values or verifying calls. - /// The type of the value returned by the member specified in the expression. - /// An expression that identifies the member of to set up. Typically, a lambda - /// expression specifying a method or property to mock. - /// An instance that can be used to further configure the behavior of - /// the mock for the specified member. - public ISetup Setup(Expression> expression) - { - return _internalMock.Setup(expression); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/TestContext.cs b/InstarBot.Tests.Common/TestContext.cs deleted file mode 100644 index 515ed1f..0000000 --- a/InstarBot.Tests.Common/TestContext.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Discord; -using InstarBot.Tests.Models; -using Moq; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Gaius; - -namespace InstarBot.Tests; - -public sealed class TestContext -{ - public ulong UserID { get; init; }= 1420070400100; - public const ulong ChannelID = 1420070400200; - public const ulong GuildID = 1420070400300; - - public HashSet UserRoles { get; init; } = []; - - public Action EmbedCallback { get; init; } = _ => { }; - - public Mock TextChannelMock { get; internal set; } = null!; - - public List GuildUsers { get; } = []; - - public Dictionary Channels { get; } = []; - public Dictionary Roles { get; } = []; - - public Dictionary> Warnings { get; } = []; - public Dictionary> Caselogs { get; } = []; - - public Dictionary> UserRolesMap { get; } = []; - - public bool InhibitGaius { get; set; } - public Mock DMChannelMock { get ; set ; } - - public void AddWarning(Snowflake userId, Warning warning) - { - if (!Warnings.TryGetValue(userId, out var list)) - Warnings[userId] = list = []; - list.Add(warning); - } - - public void AddCaselog(Snowflake userId, Caselog caselog) - { - if (!Caselogs.TryGetValue(userId, out var list)) - Caselogs[userId] = list = []; - list.Add(caselog); - } - - public void AddChannel(Snowflake channelId) - { - if (Channels.ContainsKey(channelId)) - throw new InvalidOperationException("Channel already exists."); - - Channels.Add(channelId, new TestChannel(channelId)); - } - - public ITextChannel GetChannel(Snowflake channelId) - { - return Channels[channelId]; - } - - public void AddRoles(IEnumerable roles) - { - foreach (var snowflake in roles) - if (!Roles.ContainsKey(snowflake)) - Roles.Add(snowflake, new TestRole(snowflake)); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/TestUtilities.cs b/InstarBot.Tests.Common/TestUtilities.cs index 30d14cc..678ec89 100644 --- a/InstarBot.Tests.Common/TestUtilities.cs +++ b/InstarBot.Tests.Common/TestUtilities.cs @@ -1,18 +1,4 @@ -using System.Linq.Expressions; using System.Text.RegularExpressions; -using Discord; -using Discord.Interactions; -using FluentAssertions; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Moq.Protected; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Commands; -using PaxAndromeda.Instar.ConfigModels; -using PaxAndromeda.Instar.Services; using Serilog; using Serilog.Events; @@ -20,337 +6,6 @@ namespace InstarBot.Tests; public static class TestUtilities { - private static IConfiguration? _config; - private static IDynamicConfigService? _dynamicConfig; - - public static ulong GuildID - { - get - { - var cfg = GetTestConfiguration(); - - return ulong.Parse(cfg.GetValue("TargetGuild") ?? throw new ConfigurationException("Expected TargetGuild to be set")); - } - } - - private static IConfiguration GetTestConfiguration() - { - if (_config is not null) - return _config; - - _config = new ConfigurationBuilder() - .AddJsonFile("Config/Instar.test.bare.conf.json") - .Build(); - - return _config; - } - - public static IDynamicConfigService GetDynamicConfiguration() - { - if (_dynamicConfig is not null) - return _dynamicConfig; - - #if DEBUG - var conf = new MockDynamicConfigService("Config/Instar.dynamic.test.debug.conf.json"); - #else - var conf = new MockDynamicConfigService("Config/Instar.dynamic.test.conf.json"); - #endif - - _dynamicConfig = conf; - return conf; - } - - public static TeamService GetTeamService() - { - return new TeamService(GetDynamicConfiguration()); - } - - public static IServiceProvider GetServices() - { - var sc = new ServiceCollection(); - sc.AddSingleton(GetTestConfiguration()); - sc.AddSingleton(GetTeamService()); - sc.AddSingleton(); - sc.AddSingleton(GetDynamicConfiguration()); - - return sc.BuildServiceProvider(); - } - - /// - /// Verifies that the command responded to the user with the correct . - /// - /// A mockup of the command. - /// The string format to check called messages against. - /// A flag indicating whether the message should be ephemeral. - /// A flag indicating whether partial matches are acceptable. - /// The type of command. Must implement . - public static void VerifyMessage(Mock command, string format, bool ephemeral = false, bool partial = false) - where T : BaseCommand - { - command.Protected().Verify( - "RespondAsync", - Times.Once(), - ItExpr.Is( - n => MatchesFormat(n, format, partial)), // text - ItExpr.IsAny(), // embeds - false, // isTTS - ephemeral, // ephemeral - ItExpr.IsAny(), // allowedMentions - ItExpr.IsAny(), // options - ItExpr.IsAny(), // components - ItExpr.IsAny(), // embed - ItExpr.IsAny(), // pollProperties - ItExpr.IsAny() // messageFlags - ); - } - - /// - /// Verifies that the command responded to the user with an embed that satisfies the specified . - /// - /// The type of command. Must implement . - /// A mockup of the command. - /// An instance to verify against. - /// An optional message format, if present. Defaults to null. - /// An optional flag indicating whether the message is expected to be ephemeral. Defaults to false. - /// An optional flag indicating whether partial matches are acceptable. Defaults to false. - public static void VerifyEmbed(Mock command, EmbedVerifier verifier, string? format = null, bool ephemeral = false, bool partial = false) - where T : BaseCommand - { - var msgRef = format is null - ? ItExpr.IsNull() - : ItExpr.Is(n => MatchesFormat(n, format, partial)); - - command.Protected().Verify( - "RespondAsync", - Times.Once(), - msgRef, // text - ItExpr.IsNull(), // embeds - false, // isTTS - ephemeral, // ephemeral - ItExpr.IsAny(), // allowedMentions - ItExpr.IsNull(), // options - ItExpr.IsNull(), // components - ItExpr.Is(e => verifier.Verify(e)), // embed - ItExpr.IsNull(), // pollProperties - ItExpr.IsAny() // messageFlags - ); - } - - public static void VerifyChannelMessage(Mock channel, string format, bool ephemeral = false, bool partial = false) - where T : class, IMessageChannel - { - channel.Verify(c => c.SendMessageAsync( - It.Is(s => MatchesFormat(s, format, partial)), - false, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - )); - } - - public static void VerifyChannelEmbed(Mock channel, EmbedVerifier verifier, string format, bool ephemeral = false, bool partial = false) - where T : class, ITextChannel - { - channel.Verify(c => c.SendMessageAsync( - It.Is(n => MatchesFormat(n, format, partial)), - false, - It.Is(e => verifier.Verify(e)), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - )); - } - - public static IDiscordService SetupDiscordService(TestContext context = null!) - => new MockDiscordService(SetupGuild(context)); - - public static IGaiusAPIService SetupGaiusAPIService(TestContext context = null!) - => new MockGaiusAPIService(context.Warnings, context.Caselogs, context.InhibitGaius); - - private static TestGuild SetupGuild(TestContext context = null!) - { - var guild = new TestGuild - { - Id = Snowflake.Generate(), - TextChannels = context.Channels.Values, - Roles = context.Roles.Values, - Users = context.GuildUsers - }; - - return guild; - } - - public static Mock SetupCommandMock(Expression> newExpression, TestContext context = null!) - where T : BaseCommand - { - var commandMock = new Mock(newExpression); - ConfigureCommandMock(commandMock, context); - return commandMock; - } - - public static Mock SetupCommandMock(TestContext context = null!) - where T : BaseCommand - { - // Quick check: Do we have a constructor that takes IConfiguration? - var iConfigCtor = typeof(T).GetConstructors() - .Any(n => n.GetParameters().Any(info => info.ParameterType == typeof(IConfiguration))); - - var commandMock = iConfigCtor ? new Mock(GetTestConfiguration()) : new Mock(); - ConfigureCommandMock(commandMock, context); - return commandMock; - } - - private static void ConfigureCommandMock(Mock mock, TestContext? context) - where T : BaseCommand - { - context ??= new TestContext(); - - mock.SetupGet(n => n.Context).Returns(SetupContext(context).Object); - - mock.Protected().Setup("RespondAsync", ItExpr.IsNull(), ItExpr.IsNull(), - It.IsAny(), - It.IsAny(), ItExpr.IsNull(), ItExpr.IsNull(), - ItExpr.IsNull(), - ItExpr.IsNull(), - ItExpr.IsNull(), - ItExpr.IsNull()) - .Returns(Task.CompletedTask); - } - - public static Mock SetupContext(TestContext context) - { - var mock = new Mock(); - - mock.SetupGet(static n => n.User!).Returns(SetupUserMock(context).Object); - mock.SetupGet(static n => n.Channel!).Returns(SetupChannelMock(context).Object); - // Note: The following line must occur after the mocking of GetChannel. - mock.SetupGet(static n => n.Guild).Returns(SetupGuildMock(context).Object); - - return mock; - } - - private static Mock SetupGuildMock(TestContext? context) - { - context.Should().NotBeNull(); - - var guildMock = new Mock(); - guildMock.Setup(n => n.Id).Returns(TestContext.GuildID); - guildMock.Setup(n => n.GetTextChannel(It.IsAny())) - .Returns(context.TextChannelMock.Object); - - return guildMock; - } - - public static Mock SetupUserMock(ulong userId) - where T : class, IUser - { - var userMock = new Mock(); - userMock.Setup(n => n.Id).Returns(userId); - - return userMock; - } - - private static Mock SetupUserMock(TestContext? context) - where T : class, IUser - { - var userMock = SetupUserMock(context!.UserID); - - var dmChannelMock = new Mock(); - - context.DMChannelMock = dmChannelMock; - userMock.Setup(n => n.CreateDMChannelAsync(It.IsAny())) - .ReturnsAsync(dmChannelMock.Object); - - if (typeof(T) != typeof(IGuildUser)) return userMock; - - userMock.As().Setup(n => n.RoleIds).Returns(context.UserRoles.Select(n => n.ID).ToList); - - userMock.As().Setup(n => n.AddRoleAsync(It.IsAny(), It.IsAny())) - .Callback((ulong roleId, RequestOptions _) => - { - context.UserRoles.Add(roleId); - }) - .Returns(Task.CompletedTask); - - userMock.As().Setup(n => n.RemoveRoleAsync(It.IsAny(), It.IsAny())) - .Callback((ulong roleId, RequestOptions _) => - { - context.UserRoles.Remove(roleId); - }).Returns(Task.CompletedTask); - - return userMock; - } - - public static Mock SetupChannelMock(ulong channelId) - where T : class, IChannel - { - var channelMock = new Mock(); - channelMock.Setup(n => n.Id).Returns(channelId); - - return channelMock; - } - - private static Mock SetupChannelMock(TestContext context) - where T : class, IChannel - { - var channelMock = SetupChannelMock(TestContext.ChannelID); - - if (typeof(T) != typeof(ITextChannel)) - return channelMock; - - channelMock.As().Setup(n => n.SendMessageAsync(It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Callback((string _, bool _, Embed embed, RequestOptions _, AllowedMentions _, - MessageReference _, MessageComponent _, ISticker[] _, Embed[] _, - MessageFlags _, PollProperties _) => - { - context.EmbedCallback(embed); - }) - .Returns(Task.FromResult(new Mock().Object)); - - context.TextChannelMock = channelMock.As(); - - return channelMock; - } - - public static async IAsyncEnumerable GetTeams(PageTarget pageTarget) - { - var config = await GetDynamicConfiguration().GetConfig(); - var teamsConfig = config.Teams.ToDictionary(n => n.InternalID, n => n); - - teamsConfig.Should().NotBeNull(); - - var teamRefs = pageTarget.GetAttributesOfType()?.Select(n => n.InternalID) ?? - []; - - foreach (var internalId in teamRefs) - { - if (!teamsConfig.TryGetValue(internalId, out var value)) - throw new KeyNotFoundException("Failed to find team with internal ID " + internalId); - - yield return value; - } - } - public static void SetupLogging() { Log.Logger = new LoggerConfiguration() diff --git a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj index e77e0aa..3021c4c 100644 --- a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj +++ b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj @@ -22,6 +22,7 @@ + diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs index fe04992..ead5e32 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs @@ -1,12 +1,14 @@ using Discord; using FluentAssertions; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; +using InstarBot.Test.Framework.Services; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Metrics; +using PaxAndromeda.Instar.Services; using Xunit; namespace InstarBot.Tests.Integration.Interactions; @@ -16,6 +18,7 @@ public static class AutoMemberSystemCommandTests private const ulong NewMemberRole = 796052052433698817ul; private const ulong MemberRole = 793611808372031499ul; + /* private static async Task Setup(bool setupAMH = false) { TestUtilities.SetupLogging(); @@ -24,7 +27,7 @@ private static async Task Setup(bool setupAMH = false) var userID = Snowflake.Generate(); var modID = Snowflake.Generate(); - var mockDDB = new MockInstarDDBService(); + var mockDDB = new MockDatabaseService(); var mockMetrics = new MockMetricService(); @@ -58,7 +61,7 @@ private static async Task Setup(bool setupAMH = false) ModeratorID = modID, Reason = "test reason" }; - await ddbRecord.UpdateAsync(); + await ddbRecord.CommitAsync(); } var commandMock = TestUtilities.SetupCommandMock( @@ -70,23 +73,25 @@ private static async Task Setup(bool setupAMH = false) return new Context(mockDDB, mockMetrics, user, mod, commandMock); } + */ [Fact] public static async Task HoldMember_WithValidUserAndReason_ShouldCreateRecord() { // Arrange - var ctx = await Setup(); + var orchestrator = TestOrchestrator.Default; + var cmd = orchestrator.GetCommand(); // Act - await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + await cmd.Object.HoldMember(orchestrator.Subject, "Test reason"); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Success, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberHold_Success, ephemeral: true); - var record = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + var record = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); record.Should().NotBeNull(); record.Data.AutoMemberHoldRecord.Should().NotBeNull(); - record.Data.AutoMemberHoldRecord.ModeratorID.ID.Should().Be(ctx.Moderator.Id); + record.Data.AutoMemberHoldRecord.ModeratorID.ID.Should().Be(orchestrator.Actor.Id); record.Data.AutoMemberHoldRecord.Reason.Should().Be("Test reason"); } @@ -94,77 +99,87 @@ public static async Task HoldMember_WithValidUserAndReason_ShouldCreateRecord() public static async Task HoldMember_WithNonGuildUser_ShouldGiveError() { // Arrange - var ctx = await Setup(); + var orchestrator = TestOrchestrator.Default; + var cmd = orchestrator.GetCommand(); // Act - await ctx.Command.Object.HoldMember(new TestUser(ctx.TargetUser), "Test reason"); + await cmd.Object.HoldMember(new TestUser(orchestrator.Subject), "Test reason"); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_NotGuildMember, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberHold_Error_NotGuildMember, ephemeral: true); } [Fact] public static async Task HoldMember_AlreadyAMHed_ShouldGiveError() { // Arrange - var ctx = await Setup(true); + var orchestrator = TestOrchestrator.Default; + await orchestrator.CreateAutoMemberHold(orchestrator.Subject); + var cmd = orchestrator.GetCommand(); // Act - await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + await cmd.Object.HoldMember(orchestrator.Subject, "Test reason"); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_AMHAlreadyExists, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberHold_Error_AMHAlreadyExists, ephemeral: true); } [Fact] public static async Task HoldMember_AlreadyMember_ShouldGiveError() { // Arrange - var ctx = await Setup(); - await ctx.TargetUser.AddRoleAsync(MemberRole); + var orchestrator = TestOrchestrator.Default; + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.MemberRoleID); + var cmd = orchestrator.GetCommand(); // Act - await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + await cmd.Object.HoldMember(orchestrator.Subject, "Test reason"); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_AlreadyMember, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberHold_Error_AlreadyMember, ephemeral: true); } [Fact] public static async Task HoldMember_WithDynamoDBError_ShouldRespondWithError() { // Arrange - var ctx = await Setup(); + var orchestrator = TestOrchestrator.Default; + var cmd = orchestrator.GetCommand(); - // Act - ctx.DDBService - .Setup(n => n.GetOrCreateUserAsync(It.IsAny())) - .ThrowsAsync(new BadStateException()); + if (orchestrator.Database is not IMockOf dbMock) + throw new InvalidOperationException("This test depends on the registered database implementing IMockOf"); + + dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(n => n.Id == orchestrator.Subject.Id))).Throws(); - await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + // Act + await cmd.Object.HoldMember(orchestrator.Subject, "Test reason"); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_Unexpected, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberHold_Error_Unexpected, ephemeral: true); - var record = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + var record = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); record.Should().NotBeNull(); record.Data.AutoMemberHoldRecord.Should().BeNull(); - ctx.Metrics.GetMetricValues(Metric.AMS_AMHFailures).Sum().Should().BeGreaterThan(0); + + var metrics = (TestMetricService) orchestrator.GetService(); + metrics.GetMetricValues(Metric.AMS_AMHFailures).Sum().Should().BeGreaterThan(0); } [Fact] public static async Task UnholdMember_WithValidUser_ShouldRemoveAMH() { // Arrange - var ctx = await Setup(true); + var orchestrator = TestOrchestrator.Default; + await orchestrator.CreateAutoMemberHold(orchestrator.Subject); + var cmd = orchestrator.GetCommand(); // Act - await ctx.Command.Object.UnholdMember(ctx.TargetUser); + await cmd.Object.UnholdMember(orchestrator.Subject); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Success, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberUnhold_Success, ephemeral: true); - var afterRecord = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + var afterRecord = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); afterRecord.Should().NotBeNull(); afterRecord.Data.AutoMemberHoldRecord.Should().BeNull(); } @@ -173,54 +188,52 @@ public static async Task UnholdMember_WithValidUser_ShouldRemoveAMH() public static async Task UnholdMember_WithValidUserNoActiveHold_ShouldReturnError() { // Arrange - var ctx = await Setup(); + var orchestrator = TestOrchestrator.Default; + var cmd = orchestrator.GetCommand(); // Act - await ctx.Command.Object.UnholdMember(ctx.TargetUser); + await cmd.Object.UnholdMember(orchestrator.Subject); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Error_NoActiveHold, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberUnhold_Error_NoActiveHold, ephemeral: true); } [Fact] public static async Task UnholdMember_WithNonGuildUser_ShouldReturnError() { // Arrange - var ctx = await Setup(); + var orchestrator = TestOrchestrator.Default; + var cmd = orchestrator.GetCommand(); // Act - await ctx.Command.Object.UnholdMember(new TestUser(ctx.TargetUser)); + await cmd.Object.UnholdMember(new TestUser(orchestrator.Subject)); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Error_NotGuildMember, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberUnhold_Error_NotGuildMember, ephemeral: true); } [Fact] public static async Task UnholdMember_WithDynamoError_ShouldReturnError() { // Arrange - var ctx = await Setup(true); + var orchestrator = TestOrchestrator.Default; + await orchestrator.CreateAutoMemberHold(orchestrator.Subject); + var cmd = orchestrator.GetCommand(); + + if (orchestrator.Database is not IMockOf dbMock) + throw new InvalidOperationException("This test depends on the registered database implementing IMockOf"); - ctx.DDBService - .Setup(n => n.GetOrCreateUserAsync(It.IsAny())) - .ThrowsAsync(new BadStateException()); + dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(n => n.Id == orchestrator.Subject.Id))).Throws(); // Act - await ctx.Command.Object.UnholdMember(ctx.TargetUser); + await cmd.Object.UnholdMember(orchestrator.Subject); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Error_Unexpected, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberUnhold_Error_Unexpected, ephemeral: true); // Sanity check: if the DDB errors out, the AMH should still be there - var afterRecord = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + var afterRecord = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); afterRecord.Should().NotBeNull(); afterRecord.Data.AutoMemberHoldRecord.Should().NotBeNull(); } - - private record Context( - MockInstarDDBService DDBService, - MockMetricService Metrics, - TestGuildUser TargetUser, - TestGuildUser Moderator, - Mock Command); } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs index 13dc002..14e38b9 100644 --- a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs @@ -1,12 +1,13 @@ using Discord; using FluentAssertions; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Services; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Services; using Xunit; namespace InstarBot.Tests.Integration.Interactions; @@ -16,53 +17,28 @@ public static class CheckEligibilityCommandTests private const ulong MemberRole = 793611808372031499ul; private const ulong NewMemberRole = 796052052433698817ul; - private static async Task> SetupCommandMock(CheckEligibilityCommandTestContext context, Action>? setupMocks = null) + private static async Task SetupOrchestrator(MembershipEligibility eligibility) { - TestUtilities.SetupLogging(); + var orchestrator = TestOrchestrator.Default; - var mockAMS = new Mock(); - mockAMS.Setup(n => n.CheckEligibility(It.IsAny(), It.IsAny())).Returns(context.Eligibility); + TestAutoMemberSystem tams = (TestAutoMemberSystem) orchestrator.GetService(); + tams.Mock.Setup(n => n.CheckEligibility(It.IsAny(), It.IsAny())).Returns(eligibility); - var userId = Snowflake.Generate(); - - var mockDDB = new MockInstarDDBService(); - var user = new TestGuildUser - { - Id = userId, - Username = "username", - JoinedAt = DateTimeOffset.Now, - RoleIds = context.Roles - }; - - await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(user)); - - if (context.IsAMH) + /*if (context.IsAMH) { - var ddbUser = await mockDDB.GetUserAsync(userId); - - ddbUser.Should().NotBeNull(); - ddbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Actor.Id); + dbUser.Should().NotBeNull(); + dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord { Date = DateTime.UtcNow, ModeratorID = Snowflake.Generate(), Reason = "Testing" }; - await ddbUser.UpdateAsync(); - } + await dbUser.CommitAsync(); + }*/ - var commandMock = TestUtilities.SetupCommandMock( - () => new CheckEligibilityCommand(TestUtilities.GetDynamicConfiguration(), mockAMS.Object, mockDDB, new MockMetricService()), - new TestContext - { - UserID = userId, - UserRoles = context.Roles.Select(n => new Snowflake(n)).ToHashSet() - }); - - context.DDB = mockDDB; - context.User = user; - - return commandMock; + return orchestrator; } private static EmbedVerifier.VerifierBuilder CreateVerifier() @@ -77,28 +53,30 @@ private static EmbedVerifier.VerifierBuilder CreateVerifier() public static async Task CheckEligibilityCommand_WithExistingMember_ShouldEmitValidMessage() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, [ MemberRole ], MembershipEligibility.Eligible); - var mock = await SetupCommandMock(ctx); + var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var cmd = orchestrator.GetCommand(); + + await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.MemberRoleID); // Act - await mock.Object.CheckEligibility(); + await cmd.Object.CheckEligibility(); // Assert - TestUtilities.VerifyMessage(mock, Strings.Command_CheckEligibility_Error_AlreadyMember, true); + cmd.VerifyResponse(Strings.Command_CheckEligibility_Error_AlreadyMember, ephemeral: true); } [Fact] public static async Task CheckEligibilityCommand_NoMemberRoles_ShouldEmitValidErrorMessage() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, [], MembershipEligibility.Eligible); - var mock = await SetupCommandMock(ctx); + var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var cmd = orchestrator.GetCommand(); // Act - await mock.Object.CheckEligibility(); + await cmd.Object.CheckEligibility(); // Assert - TestUtilities.VerifyMessage(mock, Strings.Command_CheckEligibility_Error_NoMemberRoles, true); + cmd.VerifyResponse(Strings.Command_CheckEligibility_Error_NoMemberRoles, ephemeral: true); } [Fact] @@ -110,19 +88,16 @@ public static async Task CheckEligibilityCommand_Eligible_ShouldEmitValidMessage .WithDescription(Strings.Command_CheckEligibility_MessagesEligibility) .Build(); - var ctx = new CheckEligibilityCommandTestContext( - false, - [ NewMemberRole ], - MembershipEligibility.Eligible); - + var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var cmd = orchestrator.GetCommand(); - var mock = await SetupCommandMock(ctx); + await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); // Act - await mock.Object.CheckEligibility(); + await cmd.Object.CheckEligibility(); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + cmd.VerifyResponse(verifier, ephemeral: true); } [Fact] @@ -136,20 +111,29 @@ public static async Task CheckEligibilityCommand_WithNewMemberAMH_ShouldEmitVali .WithField(Strings.Command_CheckEligibility_AMH_WhatToDo) .WithField(Strings.Command_CheckEligibility_AMH_ContactStaff) .Build(); + + var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var cmd = orchestrator.GetCommand(); - var ctx = new CheckEligibilityCommandTestContext( - true, - [ NewMemberRole ], - MembershipEligibility.Eligible); + await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); + // Give an AMH + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Actor.Id); + dbUser.Should().NotBeNull(); + dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + Date = DateTime.UtcNow, + ModeratorID = Snowflake.Generate(), + Reason = "Testing" + }; - var mock = await SetupCommandMock(ctx); + await dbUser.CommitAsync(); // Act - await mock.Object.CheckEligibility(); + await cmd.Object.CheckEligibility(); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + cmd.VerifyResponse(verifier, ephemeral: true); } [Fact] @@ -161,20 +145,21 @@ public static async Task CheckEligibilityCommand_DDBError_ShouldEmitValidMessage .WithDescription(Strings.Command_CheckEligibility_MessagesEligibility) .Build(); - var ctx = new CheckEligibilityCommandTestContext( - true, - [ NewMemberRole ], - MembershipEligibility.Eligible); + var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var cmd = orchestrator.GetCommand(); + + await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); - var mock = await SetupCommandMock(ctx); + if (orchestrator.Database is not IMockOf dbMock) + throw new InvalidOperationException("This test depends on the registered database implementing IMockOf"); - ctx.DDB.Setup(n => n.GetUserAsync(It.IsAny())).Throws(); + dbMock.Mock.Setup(n => n.GetUserAsync(It.IsAny())).Throws(); // Act - await mock.Object.CheckEligibility(); + await cmd.Object.CheckEligibility(); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + cmd.VerifyResponse(verifier, ephemeral: true); } [Theory] @@ -190,7 +175,7 @@ public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMes { { MembershipEligibility.MissingRoles, Strings.Command_CheckEligibility_MessagesEligibility }, { MembershipEligibility.MissingIntroduction, Strings.Command_CheckEligibility_IntroductionEligibility }, - { MembershipEligibility.InadequateTenure, Strings.Command_CheckEligibility_JoinAgeEligibility }, + { MembershipEligibility.InadequateTenure, Strings.Command_CheckEligibility_JoinAgeEligibility }, { MembershipEligibility.PunishmentReceived, Strings.Command_CheckEligibility_ModActionsEligibility }, { MembershipEligibility.NotEnoughMessages, Strings.Command_CheckEligibility_MessagesEligibility }, }; @@ -199,7 +184,7 @@ public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMes { { MembershipEligibility.MissingRoles, Strings.Command_CheckEligibility_MissingItem_Role }, { MembershipEligibility.MissingIntroduction, Strings.Command_CheckEligibility_MissingItem_Introduction }, - { MembershipEligibility.InadequateTenure, Strings.Command_CheckEligibility_MissingItem_TooYoung }, + { MembershipEligibility.InadequateTenure, Strings.Command_CheckEligibility_MissingItem_TooYoung }, { MembershipEligibility.PunishmentReceived, Strings.Command_CheckEligibility_MissingItem_PunishmentReceived }, { MembershipEligibility.NotEnoughMessages, Strings.Command_CheckEligibility_MissingItem_Messages }, }; @@ -214,114 +199,118 @@ public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMes .WithField(testFieldMap, true) .Build(); - var ctx = new CheckEligibilityCommandTestContext( - false, - [ NewMemberRole ], - eligibility); + var orchestrator = await SetupOrchestrator(eligibility); + var cmd = orchestrator.GetCommand(); - var mock = await SetupCommandMock(ctx); + await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); // Act - await mock.Object.CheckEligibility(); + await cmd.Object.CheckEligibility(); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + cmd.VerifyResponse(verifier, ephemeral: true); } [Fact] public static async Task CheckOtherEligibility_WithEligibleMember_ShouldEmitValidEmbed() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.Eligible); - var mock = await SetupCommandMock(ctx); - ctx.User.Should().NotBeNull(); + var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var cmd = orchestrator.GetCommand(); + + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); + var verifier = CreateVerifier() .WithDescription(Strings.Command_Eligibility_EligibleText) - .WithAuthorName(ctx.User.Username) + .WithAuthorName(orchestrator.Subject.Username) .Build(); // Act - await mock.Object.CheckOtherEligibility(ctx.User); + await cmd.Object.CheckOtherEligibility(orchestrator.Subject); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + cmd.VerifyResponse(verifier, ephemeral: true); } [Fact] public static async Task CheckOtherEligibility_WithIneligibleMember_ShouldEmitValidEmbed() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.MissingRoles); - var mock = await SetupCommandMock(ctx); + var orchestrator = await SetupOrchestrator(MembershipEligibility.MissingRoles); + var cmd = orchestrator.GetCommand(); - ctx.User.Should().NotBeNull(); + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); var verifier = CreateVerifier() .WithDescription(Strings.Command_Eligibility_IneligibleText) - .WithAuthorName(ctx.User.Username) + .WithAuthorName(orchestrator.Subject.Username) .WithField(Strings.Command_Eligibility_Section_Requirements, Strings.Command_CheckEligibility_RolesEligibility, true) .Build(); // Act - await mock.Object.CheckOtherEligibility(ctx.User); + await cmd.Object.CheckOtherEligibility(orchestrator.Subject); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + cmd.VerifyResponse(verifier, ephemeral: true); } [Fact] public static async Task CheckOtherEligibility_WithAMHedMember_ShouldEmitValidEmbed() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(true, [ NewMemberRole ], MembershipEligibility.MissingRoles); - var mock = await SetupCommandMock(ctx); + var orchestrator = await SetupOrchestrator(MembershipEligibility.MissingRoles); + var cmd = orchestrator.GetCommand(); - ctx.User.Should().NotBeNull(); var verifier = CreateVerifier() .WithDescription(Strings.Command_Eligibility_IneligibleText) - .WithAuthorName(ctx.User.Username) + .WithAuthorName(orchestrator.Subject.Username) .WithField(Strings.Command_Eligibility_Section_Hold, Strings.Command_Eligibility_HoldFormat) .Build(); + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); + + // Give the subject an AMH + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); + dbUser.Should().NotBeNull(); + dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + Date = DateTime.UtcNow, + ModeratorID = Snowflake.Generate(), + Reason = "Testing" + }; + + await dbUser.CommitAsync(); + // Act - await mock.Object.CheckOtherEligibility(ctx.User); + await cmd.Object.CheckOtherEligibility(orchestrator.Subject); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + cmd.VerifyResponse(verifier, ephemeral: true); } [Fact] public static async Task CheckOtherEligibility_WithDynamoError_ShouldEmitValidEmbed() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.MissingRoles); - var mock = await SetupCommandMock(ctx); + var orchestrator = await SetupOrchestrator(MembershipEligibility.MissingRoles); + var cmd = orchestrator.GetCommand(); - ctx.DDB.Setup(n => n.GetUserAsync(It.IsAny())) - .Throws(new BadStateException("Bad state")); + if (orchestrator.Database is not IMockOf dbMock) + throw new InvalidOperationException("This test depends on the registered database implementing IMockOf"); + dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(n => n.Id == orchestrator.Subject.Id))).Throws(); - ctx.User.Should().NotBeNull(); var verifier = CreateVerifier() .WithDescription(Strings.Command_Eligibility_IneligibleText) - .WithAuthorName(ctx.User.Username) + .WithAuthorName(orchestrator.Subject.Username) .WithField(Strings.Command_Eligibility_Section_Requirements, Strings.Command_CheckEligibility_RolesEligibility, true) .WithField(Strings.Command_Eligibility_Section_AmbiguousHold, Strings.Command_Eligibility_Error_AmbiguousHold) .Build(); // Act - await mock.Object.CheckOtherEligibility(ctx.User); + await cmd.Object.CheckOtherEligibility(orchestrator.Subject); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); - } - - private record CheckEligibilityCommandTestContext( - bool IsAMH, - List Roles, - MembershipEligibility Eligibility) - { - internal IGuildUser? User { get; set; } - public MockInstarDDBService DDB { get ; set ; } + cmd.VerifyResponse(verifier, ephemeral: true); } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index 260ae3b..77cba58 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -1,9 +1,11 @@ using Discord; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; +using FluentAssertions; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.ConfigModels; using Xunit; namespace InstarBot.Tests.Integration.Interactions; @@ -12,36 +14,47 @@ public static class PageCommandTests { private const string TestReason = "Test reason for paging"; - private static async Task> SetupCommandMock(PageCommandTestContext context) + private static async Task SetupOrchestrator(PageCommandTestContext context) { - // Treat the Test page target as a regular non-staff user on the server - var userTeam = context.UserTeamID == PageTarget.Test - ? Snowflake.Generate() - : (await TestUtilities.GetTeams(context.UserTeamID).FirstAsync()).ID; - - var commandMock = TestUtilities.SetupCommandMock( - () => new PageCommand(TestUtilities.GetTeamService(), new MockMetricService()), - new TestContext - { - UserRoles = [userTeam] - }); - - return commandMock; + var orchestrator = TestOrchestrator.Default; + + if (context.UserTeamID != PageTarget.Test) + await orchestrator.Actor.AddRoleAsync(GetTeamRole(orchestrator, context.UserTeamID)); + + return orchestrator; } - private static async Task GetTeamLead(PageTarget pageTarget) - { - var dynamicConfig = await TestUtilities.GetDynamicConfiguration().GetConfig(); + public static async IAsyncEnumerable GetTeams(TestOrchestrator orchestrator, PageTarget pageTarget) + { + var teamsConfig = orchestrator.Configuration.Teams.ToDictionary(n => n.InternalID, n => n); - var teamsConfig = - dynamicConfig.Teams.ToDictionary(n => n.InternalID, n => n); + teamsConfig.Should().NotBeNull(); - // Eeeeeeeeeeeeevil - return teamsConfig[pageTarget.GetAttributesOfType()?.First().InternalID ?? "idkjustfail"] - .Teamleader; - } + var teamRefs = pageTarget.GetAttributesOfType()?.Select(n => n.InternalID) ?? + []; + + foreach (var internalId in teamRefs) + { + if (!teamsConfig.TryGetValue(internalId, out var value)) + throw new KeyNotFoundException("Failed to find team with internal ID " + internalId); + + yield return value; + } + } + + private static Snowflake GetTeamRole(TestOrchestrator orchestrator, PageTarget owner) + { + var teamId = owner.GetAttributeOfType()?.InternalID; + + if (teamId is null) + throw new InvalidOperationException($"Failed to find a team for {owner}"); + + var team = orchestrator.Configuration.Teams.FirstOrDefault(n => n.InternalID.Equals(teamId, StringComparison.Ordinal)); - private static async Task VerifyPageEmbedEmitted(Mock command, PageCommandTestContext context) + return team is null ? throw new InvalidOperationException($"Failed to find a team for {owner} (internal ID: {teamId})") : team.ID; + } + + private static async Task VerifyPageEmbedEmitted(TestOrchestrator orchestrator, Mock command, PageCommandTestContext context) { var verifier = EmbedVerifier.Builder() .WithFooterText(Strings.Embed_Page_Footer) @@ -70,43 +83,44 @@ private static async Task VerifyPageEmbedEmitted(Mock command, Page { case PageTarget.All: messageFormat = string.Join(' ', - await TestUtilities.GetTeams(PageTarget.All).Select(n => Snowflake.GetMention(() => n.ID)) + await GetTeams(orchestrator, PageTarget.All).Select(n => Snowflake.GetMention(() => n.ID)) .ToArrayAsync()); break; case PageTarget.Test: messageFormat = Strings.Command_Page_TestPageMessage; break; default: - var team = await TestUtilities.GetTeams(context.PageTarget).FirstAsync(); + var team = await GetTeams(orchestrator, context.PageTarget).FirstAsync(); messageFormat = Snowflake.GetMention(() => team.ID); break; } - TestUtilities.VerifyEmbed(command, verifier.Build(), messageFormat); + command.VerifyResponse(messageFormat, verifier.Build()); } [Fact(DisplayName = "User should be able to page when authorized")] public static async Task PageCommand_Authorized_WhenPagingTeam_ShouldPageCorrectly() { - // Arrange - TestUtilities.SetupLogging(); - var context = new PageCommandTestContext( - PageTarget.Owner, - PageTarget.Moderator, - false - ); + // Arrange + var context = new PageCommandTestContext( + PageTarget.Owner, + PageTarget.Moderator, + false + ); + + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); - var command = await SetupCommandMock(context); // Act await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert - await VerifyPageEmbedEmitted(command, context); + await VerifyPageEmbedEmitted(orchestrator, command, context); } - [Fact(DisplayName = "User should be able to page a team's teamleader")] + [Fact(DisplayName = "User should be able to page a team's teamleader")] public static async Task PageCommand_Authorized_WhenPagingTeamLeader_ShouldPageCorrectly() { // Arrange @@ -116,13 +130,14 @@ public static async Task PageCommand_Authorized_WhenPagingTeamLeader_ShouldPageC true ); - var command = await SetupCommandMock(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); // Act await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert - await VerifyPageEmbedEmitted(command, context); + await VerifyPageEmbedEmitted(orchestrator, command, context); } [Fact(DisplayName = "User should be able to page a with user, channel and message")] @@ -138,13 +153,14 @@ public static async Task PageCommand_Authorized_WhenPagingWithData_ShouldPageCor Message: "" ); - var command = await SetupCommandMock(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); // Act await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, context.Message!, context.TargetUser, context.TargetChannel); // Assert - await VerifyPageEmbedEmitted(command, context); + await VerifyPageEmbedEmitted(orchestrator, command, context); } [Fact(DisplayName = "Any staff member should be able to use the Test page")] @@ -161,14 +177,15 @@ public static async Task PageCommand_AnyStaffMember_WhenPagingTest_ShouldPageCor false ); - var command = await SetupCommandMock(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); - // Act - await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, + // Act + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert - await VerifyPageEmbedEmitted(command, context); + await VerifyPageEmbedEmitted(orchestrator, command, context); } } @@ -182,13 +199,14 @@ public static async Task PageCommand_Authorized_WhenOwnerPagingAll_ShouldPageCor false ); - var command = await SetupCommandMock(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); - // Act - await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); + // Act + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert - await VerifyPageEmbedEmitted(command, context); + await VerifyPageEmbedEmitted(orchestrator, command, context); } [Fact(DisplayName = "Fail page if paging all teamleader")] @@ -201,15 +219,15 @@ public static async Task PageCommand_Authorized_WhenOwnerPagingAllTeamLead_Shoul true ); - var command = await SetupCommandMock(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); - // Act - await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); + // Act + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); - // Assert - TestUtilities.VerifyMessage( - command, - Strings.Command_Page_Error_NoAllTeamlead, + // Assert + command.VerifyResponse( + Strings.Command_Page_Error_NoAllTeamlead, true ); } @@ -224,14 +242,14 @@ public static async Task PageCommand_Unauthorized_WhenAttemptingToPage_ShouldFai false ); - var command = await SetupCommandMock(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); - // Act - await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); + // Act + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); - // Assert - TestUtilities.VerifyMessage( - command, + // Assert + command.VerifyResponse( Strings.Command_Page_Error_NotAuthorized, true ); @@ -247,14 +265,14 @@ public static async Task PageCommand_Authorized_WhenHelperAttemptsToPageAll_Shou false ); - var command = await SetupCommandMock(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); - // Act - await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); + // Act + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); - // Assert - TestUtilities.VerifyMessage( - command, + // Assert + command.VerifyResponse( Strings.Command_Page_Error_FullTeamNotAuthorized, true ); diff --git a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs index 7885fc3..de207af 100644 --- a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs @@ -1,4 +1,5 @@ -using PaxAndromeda.Instar.Commands; +using InstarBot.Test.Framework; +using PaxAndromeda.Instar.Commands; namespace InstarBot.Tests.Integration.Interactions; using Xunit; @@ -14,13 +15,13 @@ public static class PingCommandTests [Fact(DisplayName = "User should be able to issue the Ping command.")] public static async Task PingCommand_Send_ShouldEmitEphemeralPong() { - // Arrange - var command = TestUtilities.SetupCommandMock(); + // Arrange + var cmd = TestOrchestrator.Default.GetCommand(); // Act - await command.Object.Ping(); + await cmd.Object.Ping(); - // Assert - TestUtilities.VerifyMessage(command, "Pong!", true); + // Assert + cmd.VerifyResponse("Pong!", true); } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs index c71b1bf..e4044ec 100644 --- a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -1,6 +1,7 @@ using Discord; using FluentAssertions; -using InstarBot.Tests.Services; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; @@ -11,17 +12,25 @@ namespace InstarBot.Tests.Integration.Interactions; public static class ReportUserTests { + private static async Task SetupOrchestrator(ReportContext context) + { + var orchestrator = TestOrchestrator.Default; + + orchestrator.CreateChannel(context.Channel); + + return orchestrator; + } + [Fact(DisplayName = "User should be able to report a message normally")] public static async Task ReportUser_WhenReportingNormally_ShouldNotifyStaff() { var context = ReportContext.Builder() - .FromUser(Snowflake.Generate()) - .Reporting(Snowflake.Generate()) .InChannel(Snowflake.Generate()) .WithReason("This is a test report") .Build(); - var (command, interactionContext, channelMock) = SetupMocks(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); var verifier = EmbedVerifier.Builder() .WithFooterText(Strings.Embed_UserReport_Footer) @@ -33,95 +42,62 @@ public static async Task ReportUser_WhenReportingNormally_ShouldNotifyStaff() .Build(); // Act - await command.Object.HandleCommand(interactionContext.Object); + await command.Object.HandleCommand(SetupMessageCommandMock(orchestrator, context)); await command.Object.ModalResponse(new ReportMessageModal { ReportReason = context.Reason }); - // Assert - TestUtilities.VerifyMessage(command, Strings.Command_ReportUser_ReportSent, true); - - - TestUtilities.VerifyChannelEmbed(channelMock, verifier, "{0}"); - - - context.ResultEmbed.Should().NotBeNull(); - var embed = context.ResultEmbed; - - embed.Author.Should().NotBeNull(); - embed.Footer.Should().NotBeNull(); - embed.Footer!.Value.Text.Should().Be("Instar Message Reporting System"); + // Assert + command.VerifyResponse(Strings.Command_ReportUser_ReportSent, true); + + ((TestChannel) await orchestrator.Discord.GetChannel(orchestrator.Configuration.StaffAnnounceChannel)).VerifyEmbed(verifier, "{@}"); } [Fact(DisplayName = "Report user function times out if cache expires")] public static async Task ReportUser_WhenReportingNormally_ShouldFail_IfNotCompletedWithin5Minutes() { var context = ReportContext.Builder() - .FromUser(Snowflake.Generate()) - .Reporting(Snowflake.Generate()) .InChannel(Snowflake.Generate()) .WithReason("This is a test report") .Build(); - var (command, interactionContext, _) = SetupMocks(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); - // Act - await command.Object.HandleCommand(interactionContext.Object); + // Act + await command.Object.HandleCommand(SetupMessageCommandMock(orchestrator, context)); ReportUserCommand.PurgeCache(); await command.Object.ModalResponse(new ReportMessageModal { ReportReason = context.Reason }); - // Assert - TestUtilities.VerifyMessage(command, Strings.Command_ReportUser_ReportExpired, true); - } - - private static (Mock, Mock, Mock) SetupMocks(ReportContext context) - { - var commandMockContext = new TestContext - { - UserID = context.User, - EmbedCallback = embed => context.ResultEmbed = embed - }; - - var commandMock = - TestUtilities.SetupCommandMock - (() => new ReportUserCommand(TestUtilities.GetDynamicConfiguration(), new MockMetricService()), - commandMockContext); - - return (commandMock, SetupMessageCommandMock(context), commandMockContext.TextChannelMock); + // Assert + command.VerifyResponse(Strings.Command_ReportUser_ReportExpired, true); } - private static Mock SetupMessageCommandMock(ReportContext context) + private static IInstarMessageCommandInteraction SetupMessageCommandMock(TestOrchestrator orchestrator, ReportContext context) { - var userMock = TestUtilities.SetupUserMock(context.User); - var authorMock = TestUtilities.SetupUserMock(context.Sender); - - var channelMock = TestUtilities.SetupChannelMock(context.Channel); - - var messageMock = new Mock(); - messageMock.Setup(n => n.Id).Returns(100); - messageMock.Setup(n => n.Author).Returns(authorMock.Object); - messageMock.Setup(n => n.Channel).Returns(channelMock.Object); + TestChannel testChannel = (TestChannel) orchestrator.Guild.GetTextChannel(context.Channel); + var message = testChannel.AddMessage(orchestrator.Subject, "Naughty message"); - var socketMessageDataMock = new Mock(); - socketMessageDataMock.Setup(n => n.Message).Returns(messageMock.Object); + var socketMessageDataMock = new Mock(); + socketMessageDataMock.Setup(n => n.Message).Returns(message); var socketMessageCommandMock = new Mock(); - socketMessageCommandMock.Setup(n => n.User).Returns(userMock.Object); + socketMessageCommandMock.Setup(n => n.User).Returns(orchestrator.Actor); socketMessageCommandMock.Setup(n => n.Data).Returns(socketMessageDataMock.Object); socketMessageCommandMock.Setup(n => n.RespondWithModalAsync(It.IsAny(), It.IsAny(), It.IsAny>())) .Returns(Task.CompletedTask); - - return socketMessageCommandMock; + + return socketMessageCommandMock.Object; } - private record ReportContext(Snowflake User, Snowflake Sender, Snowflake Channel, string Reason) + private record ReportContext(Snowflake Channel, string Reason) { public static ReportContextBuilder Builder() { @@ -133,31 +109,9 @@ public static ReportContextBuilder Builder() private class ReportContextBuilder { - private Snowflake? _user; - private Snowflake? _sender; private Snowflake? _channel; private string? _reason; - /* - * ReportContext.Builder() - * .FromUser(user) - * .Reporting(userToReport) - * .WithContent(content) - * .InChannel(channel) - * .WithReason(reason); - */ - public ReportContextBuilder FromUser(Snowflake user) - { - _user = user; - return this; - } - - public ReportContextBuilder Reporting(Snowflake userToReport) - { - _sender = userToReport; - return this; - } - public ReportContextBuilder InChannel(Snowflake channel) { _channel = channel; @@ -172,16 +126,12 @@ public ReportContextBuilder WithReason(string reason) public ReportContext Build() { - if (_user is null) - throw new InvalidOperationException("User must be set before building ReportContext"); - if (_sender is null) - throw new InvalidOperationException("Sender must be set before building ReportContext"); if (_channel is null) throw new InvalidOperationException("Channel must be set before building ReportContext"); if (_reason is null) throw new InvalidOperationException("Reason must be set before building ReportContext"); - return new ReportContext(_user, _sender, _channel, _reason); + return new ReportContext(_channel, _reason); } } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs index cf5c450..e7c1c32 100644 --- a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs @@ -1,6 +1,8 @@ -using Discord; +using Amazon.SimpleSystemsManagement.Model; +using Discord; using FluentAssertions; -using InstarBot.Tests.Services; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Services; using Microsoft.Extensions.DependencyInjection; using Moq; using PaxAndromeda.Instar; @@ -15,119 +17,110 @@ namespace InstarBot.Tests.Integration.Interactions; public static class ResetBirthdayCommandTests { - private static async Task<(IInstarDDBService, Mock, IGuildUser, TestContext, InstarDynamicConfiguration cfg)> SetupMocks(Birthday? userBirthday = null, bool throwError = false, bool skipDbInsert = false) + private static async Task SetupOrchestrator(DateTimeOffset? userBirthday = null, bool throwsError = false) { - TestUtilities.SetupLogging(); + var orchestrator = TestOrchestrator.Default; - var ddbService = TestUtilities.GetServices().GetService(); - var cfgService = TestUtilities.GetDynamicConfiguration(); - var cfg = await cfgService.GetConfig(); - var userId = Snowflake.Generate(); + await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); - if (throwError && ddbService is MockInstarDDBService mockDDB) + if (throwsError && orchestrator.Database is TestDatabaseService tds) { - mockDDB.Setup(n => n.GetUserAsync(It.IsAny())).Throws(); + tds.Mock.Setup(n => n.GetUserAsync(It.IsAny())).Throws(); // assert that we're actually throwing an exception - await Assert.ThrowsAsync(async () => await ddbService.GetUserAsync(userId)); + await Assert.ThrowsAsync(async () => await tds.GetUserAsync(orchestrator.Actor.Id)); } - var testContext = new TestContext - { - UserID = userId - }; - - var cmd = TestUtilities.SetupCommandMock(() => new ResetBirthdayCommand(ddbService!, cfgService), testContext); - - await cmd.Object.Context.User!.AddRoleAsync(cfg.NewMemberRoleID); - - cmd.Setup(n => n.Context.User!.GuildId).Returns(TestUtilities.GuildID); - - if (!skipDbInsert) - ((MockInstarDDBService) ddbService!).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); + orchestrator.Subject = orchestrator.CreateUser(); + if (userBirthday is null) + return orchestrator; - ddbService.Should().NotBeNull(); + var dbEntry = InstarUserData.CreateFrom(orchestrator.Subject); + dbEntry.Birthday = new Birthday((DateTimeOffset) userBirthday, orchestrator.TimeProvider); + dbEntry.Birthdate = dbEntry.Birthday.Key; - if (userBirthday is null) - return (ddbService, cmd, cmd.Object.Context.User!, testContext, cfg); + await orchestrator.Database.CreateUserAsync(dbEntry); - var dbUser = await ddbService.GetUserAsync(userId); - dbUser!.Data.Birthday = userBirthday; - dbUser.Data.Birthdate = userBirthday.Key; - await dbUser.UpdateAsync(); - - return (ddbService, cmd, cmd.Object.Context.User!, testContext, cfg); + return orchestrator; } [Fact] public static async Task ResetBirthday_WithEligibleUser_ShouldHaveBirthdayReset() { // Arrange - var (ddb, cmd, user, ctx, _) = await SetupMocks(userBirthday: new Birthday(new DateTime(2000, 1, 1), TimeProvider.System)); + var orchestrator = await SetupOrchestrator(new DateTime(2000, 1, 1)); + var cmd = orchestrator.GetCommand(); + // Act - await cmd.Object.ResetBirthday(user); + await cmd.Object.ResetBirthday(orchestrator.Subject); // Assert - var dbUser = await ddb.GetUserAsync(user.Id); + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); dbUser.Should().NotBeNull(); dbUser.Data.Birthday.Should().BeNull(); dbUser.Data.Birthdate.Should().BeNull(); - TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Success, ephemeral: true); - TestUtilities.VerifyChannelMessage(ctx.DMChannelMock, Strings.Command_ResetBirthday_EndUserNotification); + cmd.VerifyResponse(Strings.Command_ResetBirthday_Success, ephemeral: true); + orchestrator.Subject.DMChannelMock.VerifyMessage(Strings.Command_ResetBirthday_EndUserNotification); } [Fact] public static async Task ResetBirthday_UserNotFound_ShouldEmitError() { // Arrange - var (ddb, cmd, user, ctx, _) = await SetupMocks(skipDbInsert: true); + var orchestrator = await SetupOrchestrator(); + var cmd = orchestrator.GetCommand(); // Act - await cmd.Object.ResetBirthday(user); + await cmd.Object.ResetBirthday(orchestrator.Subject); // Assert - var dbUser = await ddb.GetUserAsync(user.Id); + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); dbUser.Should().BeNull(); - TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Error_UserNotFound, ephemeral: true); + cmd.VerifyResponse(Strings.Command_ResetBirthday_Error_UserNotFound, ephemeral: true); } [Fact] public static async Task ResetBirthday_UserHasBirthdayRole_ShouldRemoveRole() { // Arrange - var (ddb, cmd, user, ctx, cfg) = await SetupMocks(userBirthday: new Birthday(new DateTime(2000, 1, 1), TimeProvider.System)); + var orchestrator = await SetupOrchestrator(new DateTime(2000, 1, 1)); + var birthdayRole = orchestrator.Configuration.BirthdayConfig.BirthdayRole; + + var cmd = orchestrator.GetCommand(); + + await orchestrator.Subject.AddRoleAsync(birthdayRole); - await user.AddRoleAsync(cfg.BirthdayConfig.BirthdayRole); - user.RoleIds.Should().Contain(cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().Contain(birthdayRole); // Act - await cmd.Object.ResetBirthday(user); + await cmd.Object.ResetBirthday(orchestrator.Subject); // Assert - var dbUser = await ddb.GetUserAsync(user.Id); + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); dbUser.Should().NotBeNull(); dbUser.Data.Birthday.Should().BeNull(); dbUser.Data.Birthdate.Should().BeNull(); - user.RoleIds.Should().NotContain(cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().NotContain(birthdayRole); - TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Success, ephemeral: true); - TestUtilities.VerifyChannelMessage(ctx.DMChannelMock, Strings.Command_ResetBirthday_EndUserNotification); + cmd.VerifyResponse(Strings.Command_ResetBirthday_Success, ephemeral: true); + orchestrator.Subject.DMChannelMock.VerifyMessage(Strings.Command_ResetBirthday_EndUserNotification); } [Fact] public static async Task ResetBirthday_WithDBError_ShouldEmitError() { // Arrange - var (ddb, cmd, user, ctx, _) = await SetupMocks(throwError: true); + var orchestrator = await SetupOrchestrator(throwsError: true); + var cmd = orchestrator.GetCommand(); // Act - await cmd.Object.ResetBirthday(user); + await cmd.Object.ResetBirthday(orchestrator.Subject); // Assert - TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Error_Unknown, ephemeral: true); + cmd.VerifyResponse(Strings.Command_ResetBirthday_Error_Unknown, ephemeral: true); } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs index 36989a4..1b19bc0 100644 --- a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -1,7 +1,9 @@ using Discord; using FluentAssertions; -using InstarBot.Tests.Services; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Testing.Platform.Extensions.Messages; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; @@ -14,15 +16,15 @@ namespace InstarBot.Tests.Integration.Interactions; public static class SetBirthdayCommandTests { - private static async Task<(IInstarDDBService, Mock, InstarDynamicConfiguration)> SetupMocks(SetBirthdayContext context, DateTime? timeOverride = null, bool throwError = false) - { - TestUtilities.SetupLogging(); + /*private static async Task<(IDatabaseService, Mock, InstarDynamicConfiguration)> SetupOrchestrator(SetBirthdayContext context, DateTime? timeOverride = null, bool throwError = false) + { + TestUtilities.SetupLogging(); var timeProvider = TimeProvider.System; if (timeOverride is not null) { var timeProviderMock = new Mock(); - timeProviderMock.Setup(n => n.GetUtcNow()).Returns(new DateTimeOffset((DateTime)timeOverride)); + timeProviderMock.Setup(n => n.GetUtcNow()).Returns(new DateTimeOffset((DateTime) timeOverride)); timeProvider = timeProviderMock.Object; } @@ -31,7 +33,7 @@ public static class SetBirthdayCommandTests context.StaffAnnounceChannel = staffAnnounceChannelMock; context.BirthdayAnnounceChannel = birthdayAnnounceChannelMock; - var ddbService = TestUtilities.GetServices().GetService(); + var ddbService = TestUtilities.GetServices().GetService(); var cfgService = TestUtilities.GetDynamicConfiguration(); var cfg = await cfgService.GetConfig(); @@ -57,7 +59,7 @@ public static class SetBirthdayCommandTests var birthdaySystem = new BirthdaySystem(cfgService, discord, ddbService, new MockMetricService(), timeProvider); - if (throwError && ddbService is MockInstarDDBService mockDDB) + if (throwError && ddbService is MockDatabaseService mockDDB) mockDDB.Setup(n => n.GetOrCreateUserAsync(It.IsAny())).Throws(); var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService, TestUtilities.GetDynamicConfiguration(), new MockMetricService(), birthdaySystem, timeProvider), testContext); @@ -65,20 +67,38 @@ public static class SetBirthdayCommandTests await cmd.Object.Context.User!.AddRoleAsync(cfg.NewMemberRoleID); cmd.Setup(n => n.Context.User!.GuildId).Returns(TestUtilities.GuildID); - + context.User = cmd.Object.Context.User!; cmd.Setup(n => n.Context.Guild).Returns(guildMock.Object); - ((MockInstarDDBService) ddbService).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); + ((MockDatabaseService) ddbService).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); ddbService.Should().NotBeNull(); - return (ddbService, cmd, cfg); - } + return (ddbService, cmd, cfg); + }*/ + + private const ulong NewMemberRole = 796052052433698817ul; + + private static async Task SetupOrchestrator(bool throwError = false) + { + var orchestrator = TestOrchestrator.Default; + await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); + + if (throwError) + { + if (orchestrator.Database is not IMockOf ddbService) + throw new InvalidOperationException("IDatabaseService was not mocked correctly."); + ddbService.Mock.Setup(n => n.GetOrCreateUserAsync(It.IsAny())).Throws(); + } + + return orchestrator; + } - [Theory(DisplayName = "UserID should be able to set their birthday when providing a valid date.")] + + [Theory(DisplayName = "UserID should be able to set their birthday when providing a valid date.")] [InlineData(1992, 7, 21, 0)] [InlineData(1992, 7, 21, -7)] [InlineData(1992, 7, 21, 7)] @@ -87,22 +107,25 @@ public static class SetBirthdayCommandTests [InlineData(2010, 1, 1, 0)] public static async Task SetBirthdayCommand_WithValidDate_ShouldSetCorrectly(int year, int month, int day, int timezone) { - // Arrange - var context = new SetBirthdayContext(Snowflake.Generate(), year, month, day, timezone); + // Arrange + var date = new DateTimeOffset(year, month, day, 0, 0, 0, 0, TimeSpan.FromHours(timezone)); - var (ddb, cmd, _) = await SetupMocks(context); + var orchestrator = await SetupOrchestrator(); + orchestrator.SetTime(date); + var cmd = orchestrator.GetCommand(); - // Act - await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); + // Act + await cmd.Object.SetBirthday((Month)month, day, year, timezone); - // Assert - var date = context.ToDateTime(); - - var ddbUser = await ddb.GetUserAsync(context.UserID.ID); + // Assert + + var database = orchestrator.GetService(); + + var ddbUser = await database.GetUserAsync(orchestrator.Actor.Id); ddbUser!.Data.Birthday.Should().NotBeNull(); ddbUser.Data.Birthday.Birthdate.UtcDateTime.Should().Be(date.UtcDateTime); ddbUser.Data.Birthdate.Should().Be(date.UtcDateTime.ToString("MMddHHmm")); - TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Success, true); + cmd.VerifyResponse(Strings.Command_SetBirthday_Success, true); } [Theory(DisplayName = "Attempting to set an invalid day or month number should emit an error message.")] @@ -114,28 +137,25 @@ public static async Task SetBirthdayCommand_WithValidDate_ShouldSetCorrectly(int [InlineData(2032, 2, 31)] // Leap year public static async Task SetBirthdayCommand_WithInvalidDate_ShouldReturnError(int year, int month, int day) { - // Arrange - var context = new SetBirthdayContext(Snowflake.Generate(), year, month, day); - - var (_, cmd, _) = await SetupMocks(context); + // Arrange + var orchestrator = await SetupOrchestrator(); + var cmd = orchestrator.GetCommand(); - // Act - await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); + // Act + await cmd.Object.SetBirthday((Month)month, day, year); // Assert if (month is < 0 or > 12) { - TestUtilities.VerifyMessage(cmd, - Strings.Command_SetBirthday_MonthsOutOfRange, true); + cmd.VerifyResponse(Strings.Command_SetBirthday_MonthsOutOfRange, true); } else { - var date = new DateTime(context.Year, context.Month, 1); // there's always a 1st of the month - var daysInMonth = DateTime.DaysInMonth(context.Year, context.Month); + var date = new DateTime(year, month, 1); // there's always a 1st of the month + var daysInMonth = DateTime.DaysInMonth(year, month); - // Assert - TestUtilities.VerifyMessage(cmd, - Strings.Command_SetBirthday_DaysInMonthOutOfRange, true); + // Assert + cmd.VerifyResponse(Strings.Command_SetBirthday_DaysInMonthOutOfRange, true); } } @@ -143,82 +163,76 @@ public static async Task SetBirthdayCommand_WithInvalidDate_ShouldReturnError(in public static async Task SetBirthdayCommand_WithDateInFuture_ShouldReturnError() { // Arrange - // Note: Update this in the year 9,999 - var context = new SetBirthdayContext(Snowflake.Generate(), 9999, 1, 1); - - var (_, cmd, _) = await SetupMocks(context); + var orchestrator = await SetupOrchestrator(); + var cmd = orchestrator.GetCommand(); // Act - await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + await cmd.Object.SetBirthday(Month.January, 1, 9999); // Assert - TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_NotTimeTraveler, true); + cmd.VerifyResponse(Strings.Command_SetBirthday_NotTimeTraveler, true); } [Fact(DisplayName = "Attempting to set a birthday when user has already set one should emit an error message.")] public static async Task SetBirthdayCommand_BirthdayAlreadyExists_ShouldReturnError() { // Arrange - // Note: Update this in the year 9,999 - var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); + var orchestrator = await SetupOrchestrator(); + var cmd = orchestrator.GetCommand(); - var (ddb, cmd, _) = await SetupMocks(context); - - var dbUser = await ddb.GetOrCreateUserAsync(context.User); + var database = orchestrator.GetService(); + var dbUser = await database.GetOrCreateUserAsync(orchestrator.Actor); dbUser.Data.Birthday = new Birthday(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc, TimeProvider.System); dbUser.Data.Birthdate = dbUser.Data.Birthday.Key; - await dbUser.UpdateAsync(); + await dbUser.CommitAsync(); // Act - await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + await cmd.Object.SetBirthday(Month.January, 1, 2000); // Assert - TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Error_AlreadySet, true); + cmd.VerifyResponse(Strings.Command_SetBirthday_Error_AlreadySet, true); } [Fact(DisplayName = "An exception should return a message.")] public static async Task SetBirthdayCommand_WithException_ShouldPromptUserToTryAgainLater() { // Arrange - // Note: Update this in the year 9,999 - var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); - - var (_, cmd, _) = await SetupMocks(context, throwError: true); + var orchestrator = await SetupOrchestrator(throwError: true); + var cmd = orchestrator.GetCommand(); // Act - await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + await cmd.Object.SetBirthday(Month.January, 1, 1); // Assert - TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Error_CouldNotSetBirthday, true); + cmd.VerifyResponse(Strings.Command_SetBirthday_Error_CouldNotSetBirthday, true); } [Fact(DisplayName = "Attempting to set an underage birthday should result in an AMH and staff notification.")] public static async Task SetBirthdayCommand_WithUnderage_ShouldNotifyStaff() { // Arrange - // Note: Update this in the year 9,999 - var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); - - var (ddb, cmd, cfg) = await SetupMocks(context, new DateTime(2010, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var orchestrator = await SetupOrchestrator(); + orchestrator.SetTime(new DateTime(2010, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var cmd = orchestrator.GetCommand(); // Act - await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + await cmd.Object.SetBirthday(Month.January, 1, 2000); // Assert - var date = context.ToDateTime(); + var date = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero); - var ddbUser = await ddb.GetUserAsync(context.UserID.ID); + var ddbUser = await orchestrator.Database.GetUserAsync(orchestrator.Actor.Id); ddbUser!.Data.Birthday.Should().NotBeNull(); ddbUser.Data.Birthday.Birthdate.UtcDateTime.Should().Be(date.UtcDateTime); ddbUser.Data.Birthdate.Should().Be(date.UtcDateTime.ToString("MMddHHmm")); ddbUser.Data.AutoMemberHoldRecord.Should().NotBeNull(); - ddbUser.Data.AutoMemberHoldRecord!.ModeratorID.Should().Be(cfg.BotUserID); + ddbUser.Data.AutoMemberHoldRecord!.ModeratorID.Should().Be(orchestrator.Configuration.BotUserID); - TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Success, true); + cmd.VerifyResponse(Strings.Command_SetBirthday_Success, true); - var staffAnnounceChannel = cmd.Object.Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel); + var staffAnnounceChannel = cmd.Object.Context.Guild.GetTextChannel(orchestrator.Configuration.StaffAnnounceChannel); staffAnnounceChannel.Should().NotBeNull(); @@ -226,53 +240,39 @@ public static async Task SetBirthdayCommand_WithUnderage_ShouldNotifyStaff() var embedVerifier = EmbedVerifier.Builder() .WithDescription(Strings.Embed_UnderageUser_WarningTemplate_NewMember).Build(); - TestUtilities.VerifyChannelEmbed(context.StaffAnnounceChannel, embedVerifier, $"<@&{cfg.StaffRoleID}>"); + orchestrator.GetChannel(orchestrator.Configuration.StaffAnnounceChannel) + .VerifyEmbed(embedVerifier, $"<@&{orchestrator.Configuration.StaffRoleID}>"); } [Fact(DisplayName = "Attempting to set a birthday to today should grant the birthday role.")] public static async Task SetBirthdayCommand_BirthdayIsToday_ShouldGrantBirthdayRoles() { // Arrange - // Note: Update this in the year 9,999 - var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); + var date = new DateTimeOffset(new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc)); - var (ddb, cmd, cfg) = await SetupMocks(context, new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var orchestrator = await SetupOrchestrator(); + orchestrator.SetTime(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var cmd = orchestrator.GetCommand(); // Act - await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + await cmd.Object.SetBirthday((Month) date.Month, date.Day, date.Year); // Assert - var date = context.ToDateTime(); - - context.User.RoleIds.Should().Contain(cfg.BirthdayConfig.BirthdayRole); + orchestrator.Actor.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); - var ddbUser = await ddb.GetUserAsync(context.UserID.ID); + var ddbUser = await orchestrator.Database.GetUserAsync(orchestrator.Actor.Id); ddbUser!.Data.Birthday.Should().NotBeNull(); ddbUser.Data.Birthday.Birthdate.UtcDateTime.Should().Be(date.UtcDateTime); ddbUser.Data.Birthdate.Should().Be(date.UtcDateTime.ToString("MMddHHmm")); ddbUser.Data.AutoMemberHoldRecord.Should().BeNull(); - TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Success, true); + cmd.VerifyResponse(Strings.Command_SetBirthday_Success, true); - var birthdayAnnounceChannel = cmd.Object.Context.Guild.GetTextChannel(cfg.BirthdayConfig.BirthdayAnnounceChannel); + var birthdayAnnounceChannel = cmd.Object.Context.Guild.GetTextChannel(orchestrator.Configuration.BirthdayConfig.BirthdayAnnounceChannel); birthdayAnnounceChannel.Should().NotBeNull(); - - TestUtilities.VerifyChannelMessage(context.BirthdayAnnounceChannel, Strings.Birthday_Announcement); - } - - private record SetBirthdayContext(Snowflake UserID, int Year, int Month, int Day, int TimeZone = 0) - { - public DateTimeOffset ToDateTime() - { - var unspecifiedDate = new DateTime(Year, Month, Day, 0, 0, 0, DateTimeKind.Unspecified); - var timeZone = new DateTimeOffset(unspecifiedDate, TimeSpan.FromHours(TimeZone)); - return timeZone; - } - - public Mock StaffAnnounceChannel { get; set; } - public IGuildUser User { get; set; } - public Mock BirthdayAnnounceChannel { get ; set ; } - } + orchestrator.GetChannel(orchestrator.Configuration.BirthdayConfig.BirthdayAnnounceChannel) + .VerifyMessage(Strings.Birthday_Announcement); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs index 40876a5..aceaef1 100644 --- a/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs +++ b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs @@ -1,6 +1,9 @@ using FluentAssertions; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; +using InstarBot.Test.Framework.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.DynamoModels; @@ -8,6 +11,7 @@ using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Services; using Xunit; +using static Microsoft.ApplicationInsights.MetricDimensionNames.TelemetryContext; namespace InstarBot.Tests.Integration.Services; @@ -20,85 +24,70 @@ public static class AutoMemberSystemTests private static readonly Snowflake SheHer = new(796578609535647765); private static readonly Snowflake AutoMemberHold = new(966434762032054282); - private static async Task SetupTest(AutoMemberSystemContext scenarioContext) - { - var testContext = scenarioContext.TestContext; - - var discordService = TestUtilities.SetupDiscordService(testContext); - var gaiusApiService = TestUtilities.SetupGaiusAPIService(testContext); - var config = TestUtilities.GetDynamicConfiguration(); - - scenarioContext.DiscordService = discordService; - var userId = scenarioContext.UserID; - var relativeJoinTime = scenarioContext.HoursSinceJoined; - var roles = scenarioContext.Roles; - var postedIntro = scenarioContext.PostedIntroduction; - var messagesLast24Hours = scenarioContext.MessagesLast24Hours; - var firstSeenTime = scenarioContext.FirstJoinTime; - var grantedMembershipBefore = scenarioContext.GrantedMembershipBefore; - - var amsConfig = scenarioContext.Config.AutoMemberConfig; + private static async Task SetupOrchestrator(AutoMemberSystemContext context) + { + var orchestrator = await TestOrchestrator.Builder + .WithSubject(new TestGuildUser + { + Username = "username", + JoinedAt = DateTimeOffset.Now - TimeSpan.FromHours(context.HoursSinceJoined), + RoleIds = context.Roles.Select(n => n.ID).ToList().AsReadOnly() + }) + .WithService() + .Build(); - var ddbService = new MockInstarDDBService(); + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); + var channel = orchestrator.CreateChannel(Snowflake.Generate()); - var user = new TestGuildUser + if (context.PostedIntroduction) { - Id = userId, - Username = "username", - JoinedAt = DateTimeOffset.Now - TimeSpan.FromHours(relativeJoinTime), - RoleIds = roles.Select(n => n.ID).ToList().AsReadOnly() - }; - - var userData = InstarUserData.CreateFrom(user); - userData.Position = grantedMembershipBefore ? InstarUserPosition.Member : InstarUserPosition.NewMember; - - if (!scenarioContext.SuppressDDBEntry) - ddbService.Register(userData); - - testContext.AddRoles(roles); - - testContext.GuildUsers.Add(user); - - - var genericChannel = Snowflake.Generate(); - testContext.AddChannel(amsConfig.IntroductionChannel); - - testContext.AddChannel(genericChannel); - if (postedIntro) - ((TestChannel) testContext.GetChannel(amsConfig.IntroductionChannel)).AddMessage(user, "Some text"); + TestChannel introChannel = (TestChannel) await orchestrator.Discord.GetChannel(orchestrator.Configuration.AutoMemberConfig.IntroductionChannel); + introChannel.AddMessage(orchestrator.Subject, "Some introduction"); + } + + for (var i = 0; i < context.MessagesLast24Hours; i++) + channel.AddMessage(orchestrator.Subject, "Some text"); - for (var i = 0; i < messagesLast24Hours; i++) - ((TestChannel)testContext.GetChannel(genericChannel)).AddMessage(user, "Some text"); + if (context.GrantedMembershipBefore) + { + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); + dbUser.Data.Position = InstarUserPosition.Member; + await dbUser.CommitAsync(); + } + if (context.GaiusInhibited) + { + TestGaiusAPIService gaiusMock = (TestGaiusAPIService) orchestrator.GetService(); + gaiusMock.Inhibit(); + } - var ams = new AutoMemberSystem(config, discordService, gaiusApiService, ddbService, new MockMetricService(), TimeProvider.System); + var ams = (AutoMemberSystem) orchestrator.GetService(); await ams.Initialize(); - scenarioContext.User = user; - scenarioContext.DynamoService = ddbService; - - return ams; - } + orchestrator.Subject.Reset(); + return orchestrator; + } [Fact(DisplayName = "Eligible users should be granted membership")] public static async Task AutoMemberSystem_EligibleUser_ShouldBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); // Act await ams.RunAsync(); // Assert - context.AssertMember(); + context.AssertMember(orchestrator); } @@ -106,20 +95,21 @@ public static async Task AutoMemberSystem_EligibleUser_ShouldBeGrantedMembership public static async Task AutoMemberSystem_EligibleUserWithAMH_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer, AutoMemberHold) .HasPostedIntroduction() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } @@ -127,146 +117,153 @@ public static async Task AutoMemberSystem_EligibleUserWithAMH_ShouldNotBeGranted public static async Task AutoMemberSystem_NewUser_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(12)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "Inactive users should not be granted membership.")] public static async Task AutoMemberSystem_InactiveUser_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() .WithMessages(10) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "Auto Member System should not affect Members.")] public static async Task AutoMemberSystem_Member_ShouldNotBeChanged() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(Member, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertUserUnchanged(); + context.AssertUserUnchanged(orchestrator); } [Fact(DisplayName = "A user that did not post an introduction should not be granted membership")] public static async Task AutoMemberSystem_NoIntroduction_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .WithMessages(100) .Build(); - var ams = await SetupTest(context); - - // Act - await ams.RunAsync(); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); + // Act + await ams.RunAsync(); + // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "A user without an age role should not be granted membership")] public static async Task AutoMemberSystem_NoAgeRole_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, SheHer) .HasPostedIntroduction() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "A user without a gender role should not be granted membership")] public static async Task AutoMemberSystem_NoGenderRole_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, TwentyOnePlus, SheHer) .HasPostedIntroduction() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "A user without a pronoun role should not be granted membership")] public static async Task AutoMemberSystem_NoPronounRole_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus) .HasPostedIntroduction() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "A user with a warning should not be granted membership")] public static async Task AutoMemberSystem_UserWithGaiusWarning_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() @@ -274,20 +271,32 @@ public static async Task AutoMemberSystem_UserWithGaiusWarning_ShouldNotBeGrante .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = (AutoMemberSystem) orchestrator.GetService(); - // Act - await ams.RunAsync(); + TestGaiusAPIService gaiusMock = (TestGaiusAPIService) orchestrator.GetService(); + gaiusMock.AddWarning(orchestrator.Subject, new Warning + { + Reason = "TEST PUNISHMENT", + ModID = Snowflake.Generate(), + UserID = orchestrator.Subject.Id + }); + + // reload the Gaius warnings + await ams.Initialize(); + + // Act + await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "A user with a caselog should not be granted membership")] public static async Task AutoMemberSystem_UserWithGaiusCaselog_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() @@ -295,20 +304,33 @@ public static async Task AutoMemberSystem_UserWithGaiusCaselog_ShouldNotBeGrante .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = (AutoMemberSystem) orchestrator.GetService(); + + TestGaiusAPIService gaiusMock = (TestGaiusAPIService) orchestrator.GetService(); + gaiusMock.AddCaselog(orchestrator.Subject, new Caselog + { + Type = CaselogType.Mute, + Reason = "TEST PUNISHMENT", + ModID = Snowflake.Generate(), + UserID = orchestrator.Subject.Id + }); + + // reload the Gaius caselogs + await ams.Initialize(); // Act await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "A user with a join age auto kick should be granted membership")] public static async Task AutoMemberSystem_UserWithJoinAgeKick_ShouldBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() @@ -316,20 +338,33 @@ public static async Task AutoMemberSystem_UserWithJoinAgeKick_ShouldBeGrantedMem .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = (AutoMemberSystem) orchestrator.GetService(); + + TestGaiusAPIService gaiusMock = (TestGaiusAPIService) orchestrator.GetService(); + gaiusMock.AddCaselog(orchestrator.Subject, new Caselog + { + Type = CaselogType.Kick, + Reason = "Join age punishment", + ModID = Snowflake.Generate(), + UserID = orchestrator.Subject.Id + }); + + // reload the Gaius caselogs + await ams.Initialize(); // Act await ams.RunAsync(); // Assert - context.AssertMember(); + context.AssertMember(orchestrator); } [Fact(DisplayName = "A user should be granted membership if Gaius is unavailable")] public static async Task AutoMemberSystem_GaiusIsUnavailable_ShouldBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() @@ -337,20 +372,21 @@ public static async Task AutoMemberSystem_GaiusIsUnavailable_ShouldBeGrantedMemb .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = (AutoMemberSystem) orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertMember(); + context.AssertMember(orchestrator); } [Fact(DisplayName = "A user should be granted membership if they have been granted membership before")] public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(1)) .FirstJoined(TimeSpan.FromDays(7)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) @@ -359,11 +395,12 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe .WithMessages(100) .Build(); - await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - var service = context.DiscordService as MockDiscordService; - var user = context.User; + // Act + var service = orchestrator.Discord as TestDiscordService; + var user = orchestrator.Subject; service.Should().NotBeNull(); user.Should().NotBeNull(); @@ -371,7 +408,7 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe await service.TriggerUserJoined(user); // Assert - context.AssertMember(); + context.AssertMember(orchestrator); } [Fact(DisplayName = "The Dynamo record for a user should be updated when the user's username changes")] @@ -380,7 +417,7 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte // Arrange const string newUsername = "fred"; - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(1)) .FirstJoined(TimeSpan.FromDays(7)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) @@ -389,22 +426,22 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte .WithMessages(100) .Build(); - await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); // Make sure the user is in the database - context.DynamoService.Should().NotBeNull(because: "Test is invalid if DynamoService is not set"); - await context.DynamoService.CreateUserAsync(InstarUserData.CreateFrom(context.User!)); + await orchestrator.Database.CreateUserAsync(InstarUserData.CreateFrom(orchestrator.Subject)); // Act - MockDiscordService mds = (MockDiscordService) context.DiscordService!; + var mds = (TestDiscordService) orchestrator.Discord; - var newUser = context.User!.Clone(); + var newUser = orchestrator.Subject.Clone(); newUser.Username = newUsername; - await mds.TriggerUserUpdated(new UserUpdatedEventArgs(context.UserID, context.User, newUser)); + await mds.TriggerUserUpdated(new UserUpdatedEventArgs(orchestrator.Subject.Id, orchestrator.Subject, newUser)); // Assert - var ddbUser = await context.DynamoService.GetUserAsync(context.UserID); + var ddbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); ddbUser.Should().NotBeNull(); ddbUser.Data.Username.Should().Be(newUsername); @@ -419,7 +456,7 @@ public static async Task AutoMemberSystem_MemberEligibleButMissingInDDB_ShouldBe { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() @@ -427,17 +464,17 @@ public static async Task AutoMemberSystem_MemberEligibleButMissingInDDB_ShouldBe .SuppressDDBEntry() .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); // Act await ams.RunAsync(); // Assert - context.AssertMember(); + context.AssertMember(orchestrator); } private record AutoMemberSystemContext( - Snowflake UserID, int HoursSinceJoined, Snowflake[] Roles, bool PostedIntroduction, @@ -445,33 +482,28 @@ private record AutoMemberSystemContext( int FirstJoinTime, bool GrantedMembershipBefore, bool SuppressDDBEntry, - TestContext TestContext, - InstarDynamicConfiguration Config) + bool GaiusInhibited) { public static AutoMemberSystemContextBuilder Builder() => new(); - public IDiscordService? DiscordService { get; set; } - public TestGuildUser? User { get; set; } - public IInstarDDBService? DynamoService { get ; set ; } - - public void AssertMember() + public void AssertMember(TestOrchestrator orchestrator) { - User.Should().NotBeNull(); - User.RoleIds.Should().Contain(Member.ID); - User.RoleIds.Should().NotContain(NewMember.ID); + orchestrator.Subject.Should().NotBeNull(); + orchestrator.Subject.RoleIds.Should().Contain(Member.ID); + orchestrator.Subject.RoleIds.Should().NotContain(NewMember.ID); } - public void AssertNotMember() + public void AssertNotMember(TestOrchestrator orchestrator) { - User.Should().NotBeNull(); - User.RoleIds.Should().NotContain(Member.ID); - User.RoleIds.Should().Contain(NewMember.ID); + orchestrator.Subject.Should().NotBeNull(); + orchestrator.Subject.RoleIds.Should().NotContain(Member.ID); + orchestrator.Subject.RoleIds.Should().Contain(NewMember.ID); } - public void AssertUserUnchanged() + public void AssertUserUnchanged(TestOrchestrator orchestrator) { - User.Should().NotBeNull(); - User.Changed.Should().BeFalse(); + orchestrator.Subject.Should().NotBeNull(); + orchestrator.Subject.Changed.Should().BeFalse(); } } @@ -495,6 +527,7 @@ public AutoMemberSystemContextBuilder Joined(TimeSpan timeAgo) _hoursSinceJoined = (int) Math.Round(timeAgo.TotalHours); return this; } + public AutoMemberSystemContextBuilder SetRoles(params Snowflake[] roles) { _roles = roles; @@ -551,39 +584,9 @@ public AutoMemberSystemContextBuilder SuppressDDBEntry() return this; } - public async Task Build() + public AutoMemberSystemContext Build() { - var config = await TestUtilities.GetDynamicConfiguration().GetConfig(); - - var testContext = new TestContext(); - - var userId = Snowflake.Generate(); - - // Set up any warnings or whatnot - testContext.InhibitGaius = !_gaiusAvailable; - - if (_gaiusPunished) - { - testContext.AddCaselog(userId, new Caselog - { - Type = _joinAgeKick ? CaselogType.Kick : CaselogType.Mute, - Reason = _joinAgeKick ? "Join age punishment" : "TEST PUNISHMENT", - ModID = Snowflake.Generate(), - UserID = userId - }); - } - if (_gaiusWarned) - { - testContext.AddWarning(userId, new Warning - { - Reason = "TEST PUNISHMENT", - ModID = Snowflake.Generate(), - UserID = userId - }); - } - return new AutoMemberSystemContext( - userId, _hoursSinceJoined, _roles ?? throw new InvalidOperationException("Roles must be set."), _postedIntroduction, @@ -591,8 +594,7 @@ public async Task Build() _firstJoinTime, _grantedMembershipBefore, _suppressDDB, - testContext, - config); + _gaiusAvailable); } } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs index 6ef494e..3ced526 100644 --- a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs +++ b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs @@ -1,11 +1,9 @@ -using Amazon.DynamoDBv2.DataModel; -using FluentAssertions; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; +using FluentAssertions; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.ConfigModels; -using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; using Xunit; using Metric = PaxAndromeda.Instar.Metrics.Metric; @@ -14,91 +12,39 @@ namespace InstarBot.Tests.Integration.Services; public static class BirthdaySystemTests { - private static async Task Setup(DateTime todayLocal, DateTimeOffset? birthdate = null, bool applyBirthday = false, Func, InstarDynamicConfiguration, List>? roleUpdateFn = null) + private static async Task SetupOrchestrator(DateTimeOffset currentTime, DateTimeOffset? birthdate = null) { - var today = todayLocal.ToUniversalTime(); - var timeProviderMock = new Mock(); - timeProviderMock.Setup(n => n.GetUtcNow()).Returns(today); + var orchestrator = await TestOrchestrator.Builder + .WithTime(currentTime) + .WithService() + .Build(); - Birthday? birthday = birthdate is null ? null : new Birthday(birthdate.Value, timeProviderMock.Object); - - var testUserId = Snowflake.Generate(); - var cfg = TestUtilities.GetDynamicConfiguration(); - var mockDDB = new MockInstarDDBService(); - var metrics = new MockMetricService(); - var polledCfg = await cfg.GetConfig(); - - var discord = TestUtilities.SetupDiscordService(new TestContext + if (birthdate is not null) { - UserID = Snowflake.Generate(), - Channels = - { - { polledCfg.BirthdayConfig.BirthdayAnnounceChannel, new TestChannel(polledCfg.BirthdayConfig.BirthdayAnnounceChannel) } - } - }) as MockDiscordService; - - discord.Should().NotBeNull(); - - var user = new TestGuildUser - { - Id = testUserId, - Username = "TestUser" - }; - - var rolesToAdd = new List(); // Some random role - - if (applyBirthday) - rolesToAdd.Add(polledCfg.BirthdayConfig.BirthdayRole); + var dbUser = await orchestrator.Database.GetOrCreateUserAsync(orchestrator.Subject); + dbUser.Data.Birthday = new Birthday((DateTimeOffset) birthdate, orchestrator.TimeProvider); + dbUser.Data.Birthdate = dbUser.Data.Birthday.Key; + await dbUser.CommitAsync(); - if (birthday is not null) - { - int priorYearsOld = birthday.Age - 1; - var ageRole = polledCfg.BirthdayConfig.AgeRoleMap + int priorYearsOld = dbUser.Data.Birthday.Age - 1; + var ageRole = orchestrator.Configuration.BirthdayConfig.AgeRoleMap .OrderByDescending(n => n.Age) .SkipWhile(n => n.Age > priorYearsOld) .First(); - rolesToAdd.Add(ageRole.Role.ID); - } - - if (roleUpdateFn is not null) - rolesToAdd = roleUpdateFn(rolesToAdd, polledCfg); - - await user.AddRolesAsync(rolesToAdd); - - - discord.AddUser(user); - - var dbUser = InstarUserData.CreateFrom(user); - - if (birthday is not null) - { - dbUser.Birthday = birthday; - dbUser.Birthdate = birthday.Key; + await orchestrator.Subject.AddRoleAsync(ageRole.Role.ID); - if (birthday.IsToday) + if (dbUser.Data.Birthday.IsToday) { - mockDDB.Setup(n => n.GetUsersByBirthday(today, It.IsAny())) - .ReturnsAsync([new InstarDatabaseEntry(Mock.Of(), dbUser)]); + var mockDDB = (IMockOf) orchestrator.Database; + mockDDB.Mock.Setup(n => n.GetUsersByBirthday(currentTime.UtcDateTime, It.IsAny())) + .ReturnsAsync([dbUser]); } } - await mockDDB.CreateUserAsync(dbUser); + await ((BirthdaySystem) orchestrator.GetService()).Initialize(); - - - var birthdaySystem = new BirthdaySystem(cfg, discord, mockDDB, metrics, timeProviderMock.Object); - - return new Context(testUserId, birthdaySystem, mockDDB, discord, metrics, polledCfg, birthday); - } - - private static bool IsDateMatch(DateTime a, DateTime b) - { - // Match everything but year - var aUtc = a.ToUniversalTime(); - var bUtc = b.ToUniversalTime(); - - return aUtc.Month == bUtc.Month && aUtc.Day == bUtc.Day && aUtc.Hour == bUtc.Hour; + return orchestrator; } [Fact] @@ -108,29 +54,27 @@ public static async Task BirthdaySystem_WhenUserBirthday_ShouldGrantRole() var birthday = DateTime.Parse("2000-02-14T00:00:00Z"); var today = DateTime.Parse("2025-02-14T00:00:00Z"); - var ctx = await Setup(today, birthday); + var orchestrator = await SetupOrchestrator(today, birthday); + var system = orchestrator.GetService(); // Act - await ctx.System.RunAsync(); + await system.RunAsync(); // Assert // We expect a few things here: the test user should now have the birthday // role, and there should now be a message in the birthday announce channel. + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.AgeRoleMap.MaxBy(n => n.Age)!.Role.ID); - var user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); - user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.AgeRoleMap.MaxBy(n => n.Age)!.Role.ID); - - var channel = await ctx.Discord.GetChannel(ctx.Cfg.BirthdayConfig.BirthdayAnnounceChannel) as TestChannel; + var channel = await orchestrator.Discord.GetChannel(orchestrator.Configuration.BirthdayConfig.BirthdayAnnounceChannel) as TestChannel; channel.Should().NotBeNull(); var messages = await channel.GetMessagesAsync().SelectMany(n => n).ToListAsync(); messages.Count.Should().BeGreaterThan(0); TestUtilities.MatchesFormat(Strings.Birthday_Announcement, messages[0].Content); - - ctx.Metrics.GetMetricValues(Metric.BirthdaySystem_Failures).Sum().Should().Be(0); - ctx.Metrics.GetMetricValues(Metric.BirthdaySystem_Grants).Sum().Should().Be(1); + + orchestrator.Metrics.GetMetricValues(Metric.BirthdaySystem_Failures).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.BirthdaySystem_Grants).Sum().Should().Be(1); } [Fact] @@ -140,30 +84,27 @@ public static async Task BirthdaySystem_WhenUserBirthday_ShouldUpdateAgeRoles() var birthdate = DateTime.Parse("2000-02-14T00:00:00Z"); var today = DateTime.Parse("2016-02-14T00:00:00Z"); - var ctx = await Setup(today, birthdate); + var orchestrator = await SetupOrchestrator(today, birthdate); + var system = orchestrator.GetService(); - int yearsOld = ctx.Birthday.Age; + int yearsOld = new Birthday(birthdate, orchestrator.TimeProvider).Age; - var priorAgeSnowflake = ctx.Cfg.BirthdayConfig.AgeRoleMap.First(n => n.Age == yearsOld - 1).Role; - var newAgeSnowflake = ctx.Cfg.BirthdayConfig.AgeRoleMap.First(n => n.Age == yearsOld).Role; + var priorAgeSnowflake = orchestrator.Configuration.BirthdayConfig.AgeRoleMap.First(n => n.Age == yearsOld - 1).Role; + var newAgeSnowflake = orchestrator.Configuration.BirthdayConfig.AgeRoleMap.First(n => n.Age == yearsOld).Role; // Preassert - var user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().Contain(priorAgeSnowflake); - user.RoleIds.Should().NotContain(newAgeSnowflake); + orchestrator.Subject.RoleIds.Should().Contain(priorAgeSnowflake); + orchestrator.Subject.RoleIds.Should().NotContain(newAgeSnowflake); // Act - await ctx.System.RunAsync(); + await system.RunAsync(); // Assert // The main thing we're looking for in this test is whether the previous age // role was removed and the new one applied. - user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().NotContain(priorAgeSnowflake); - user.RoleIds.Should().Contain(newAgeSnowflake); + orchestrator.Subject.RoleIds.Should().NotContain(priorAgeSnowflake); + orchestrator.Subject.RoleIds.Should().Contain(newAgeSnowflake); } [Fact] @@ -173,30 +114,25 @@ public static async Task BirthdaySystem_WhenUserBirthdayWithNoYear_ShouldNotUpda var birthday = DateTime.Parse("1600-02-14T00:00:00Z"); var today = DateTime.Parse("2016-02-14T00:00:00Z"); - var ctx = await Setup(today, birthday, roleUpdateFn: (_, cfg) => - { - // just return the 16 age role - return [ cfg.BirthdayConfig.AgeRoleMap.First(n => n.Age == 16).Role.ID ]; - }); + var orchestrator = await SetupOrchestrator(today, birthday); + await orchestrator.Subject.RemoveRolesAsync(orchestrator.Subject.RoleIds); + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.BirthdayConfig.AgeRoleMap.First(n => n.Age == 16).Role.ID); - // Preassert - var user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); + var system = orchestrator.GetService(); - user.RoleIds.Should().ContainSingle(); - var priorRoleId = user.RoleIds.First(); + // Preassert + orchestrator.Subject.RoleIds.Should().ContainSingle(); + var priorRoleId = orchestrator.Subject.RoleIds.First(); // Act - await ctx.System.RunAsync(); + await system.RunAsync(); // Assert - user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); // Since the user's birth year isn't set, we can't actually calculate their age, // so we expect that their age roles won't be changed. - user.RoleIds.Should().HaveCount(2); - user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); - user.RoleIds.Should().Contain(priorRoleId); + orchestrator.Subject.RoleIds.Should().HaveCount(2); + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().Contain(priorRoleId); } [Fact] @@ -206,17 +142,16 @@ public static async Task BirthdaySystem_WhenNoBirthdays_ShouldDoNothing() var birthday = DateTime.Parse("2000-02-14T00:00:00Z"); var today = DateTime.Parse("2025-02-17T00:00:00Z"); - var ctx = await Setup(today, birthday); + var orchestrator = await SetupOrchestrator(today, birthday); + var system = orchestrator.GetService(); // Act - await ctx.System.RunAsync(); + await system.RunAsync(); // Assert - var user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().NotContain(ctx.Cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().NotContain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); - var channel = await ctx.Discord.GetChannel(ctx.Cfg.BirthdayConfig.BirthdayAnnounceChannel) as TestChannel; + var channel = await orchestrator.Discord.GetChannel(orchestrator.Configuration.BirthdayConfig.BirthdayAnnounceChannel) as TestChannel; channel.Should().NotBeNull(); var messages = await channel.GetMessagesAsync().SelectMany(n => n).ToListAsync(); @@ -230,21 +165,21 @@ public static async Task BirthdaySystem_WithUserHavingOldBirthday_ShouldRemoveOl var birthday = DateTime.Parse("2000-02-13T00:00:00Z"); var today = DateTime.Parse("2025-02-14T00:00:00Z"); - var ctx = await Setup(today, birthday, true); + var orchestrator = await SetupOrchestrator(today, birthday); + var system = orchestrator.GetService(); + + // Add the birthday role + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.BirthdayConfig.BirthdayRole); // Pre assert - var user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); // Act - await ctx.System.RunAsync(); + await system.RunAsync(); // Assert - user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().NotContain(ctx.Cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().NotContain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); } [Fact] @@ -254,21 +189,22 @@ public static async Task BirthdaySystem_WithBirthdayRoleButNoBirthdayRecord_Shou var birthday = DateTime.Parse("2000-02-13T00:00:00Z"); var today = DateTime.Parse("2025-02-14T00:00:00Z"); - var ctx = await Setup(today, applyBirthday: true); + var orchestrator = await SetupOrchestrator(today, birthday); + var system = orchestrator.GetService(); + + // Add the birthday role + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.BirthdayConfig.BirthdayRole); + // Pre assert - var user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); // Act - await ctx.System.RunAsync(); + await system.RunAsync(); // Assert - user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().NotContain(ctx.Cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().NotContain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); } [Fact] @@ -278,29 +214,19 @@ public static async Task BirthdaySystem_WithUserBirthdayStill_ShouldKeepBirthday var birthday = DateTime.Parse("2000-02-13T12:00:00Z"); var today = DateTime.Parse("2025-02-14T00:00:00Z"); - var ctx = await Setup(today, birthday, true); + var orchestrator = await SetupOrchestrator(today, birthday); + var system = orchestrator.GetService(); + + // Add the birthday role + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.BirthdayConfig.BirthdayRole); // Pre assert - var user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); // Act - await ctx.System.RunAsync(); + await system.RunAsync(); // Assert - user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); } - - private record Context( - Snowflake TestUserId, - BirthdaySystem System, - MockInstarDDBService DDB, - MockDiscordService Discord, - MockMetricService Metrics, - InstarDynamicConfiguration Cfg, - Birthday? Birthday - ); } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/TestTest.cs b/InstarBot.Tests.Integration/TestTest.cs new file mode 100644 index 0000000..73c571a --- /dev/null +++ b/InstarBot.Tests.Integration/TestTest.cs @@ -0,0 +1,49 @@ +using Discord; +using FluentAssertions; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.Services; +using Xunit; + +namespace InstarBot.Tests.Integration; + +public class TestTest +{ + private const ulong NewMemberRole = 796052052433698817ul; + + private static async Task<(TestOrchestrator, Mock)> SetupMocks2(DateTimeOffset? timeOverride = null, bool throwError = false) + { + var orchestrator = await TestOrchestrator.Builder + .WithTime(timeOverride) + .WithService() + .Build(); + + await orchestrator.Actor.AddRoleAsync(NewMemberRole); + + // yoooo, this is going to be so nice if this all works + var cmd = orchestrator.GetCommand(); + + if (throwError) + { + if (orchestrator.GetService() is not IMockOf ddbService) + throw new InvalidOperationException("IDatabaseService was not mocked correctly."); + + ddbService.Mock.Setup(n => n.GetOrCreateUserAsync(It.IsAny())).Throws(); + } + + return (orchestrator, cmd); + } + + [Fact] + public async Task Test() + { + var (orchestrator, mock) = await SetupMocks2(DateTimeOffset.UtcNow); + + var guildUser = orchestrator.Actor as IGuildUser; + + guildUser.RoleIds.Should().Contain(NewMemberRole); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/IMockOf.cs b/InstarBot.Tests.Orchestrator/IMockOf.cs new file mode 100644 index 0000000..57fb0b4 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/IMockOf.cs @@ -0,0 +1,15 @@ +using Moq; + +namespace InstarBot.Test.Framework; + +/// +/// Represents an object that provides access to the underlying mock for a specified type. +/// +/// This interface is typically used to expose the mock instance associated with a particular type, +/// enabling advanced configuration or verification in unit tests. It is commonly implemented by test doubles or helper +/// classes that encapsulate a mock object. +/// The type of the object being mocked. Must be a reference type. +public interface IMockOf where T : class +{ + Mock Mock { get; } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj b/InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj new file mode 100644 index 0000000..76bb766 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/InstarBot.Tests.Orchestrator/MockExtensions.cs b/InstarBot.Tests.Orchestrator/MockExtensions.cs new file mode 100644 index 0000000..d29e7fe --- /dev/null +++ b/InstarBot.Tests.Orchestrator/MockExtensions.cs @@ -0,0 +1,100 @@ +using Discord; +using InstarBot.Tests; +using Moq; +using Moq.Protected; +using PaxAndromeda.Instar.Commands; + +namespace InstarBot.Test.Framework; + +public static class MockExtensions +{ + extension(Mock channel) where T : class, IMessageChannel + { + /// + /// Verifies that the command responded to the user with the correct . + /// + /// The string format to check called messages against. + /// A flag indicating whether partial matches are acceptable. + public void VerifyMessage(string format, bool partial = false) + { + channel.Verify(c => c.SendMessageAsync( + It.Is(s => TestUtilities.MatchesFormat(s, format, partial)), + false, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )); + } + } + + extension(Mock channel) where T : class, ITextChannel + { + /// + /// Verifies that the command responded to the user with an embed that satisfies the specified . + /// + /// The type of command. Must implement . + /// An instance to verify against. + /// An optional message format, if present. Defaults to null. + /// An optional flag indicating whether partial matches are acceptable. Defaults to false. + public void VerifyMessageEmbed(EmbedVerifier verifier, string format, bool partial = false) + { + channel.Verify(c => c.SendMessageAsync( + It.Is(n => TestUtilities.MatchesFormat(n, format, partial)), + false, + It.Is(e => verifier.Verify(e)), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )); + } + } + + extension(Mock command) where T : BaseCommand + { + public void VerifyResponse(string format, bool ephemeral = false, bool partial = false) + => command.VerifyResponseAndEmbed(format, null, ephemeral, partial); + + public void VerifyResponse(EmbedVerifier embedVerifier, bool ephemeral = false, bool partial = false) + => command.VerifyResponseAndEmbed(null, embedVerifier, ephemeral, partial); + + public void VerifyResponse(string format, EmbedVerifier embedVerifier, bool ephemeral = false, bool partial = false) + => command.VerifyResponseAndEmbed(format, embedVerifier, ephemeral, partial); + + private void VerifyResponseAndEmbed(string? format = null, EmbedVerifier? embedVerifier = null, bool ephemeral = false, bool partial = false) + { + var msgRef = format is null + ? ItExpr.IsNull() + : ItExpr.Is(n => TestUtilities.MatchesFormat(n, format, partial)); + + var embedRef = embedVerifier is null + ? ItExpr.IsAny() + : ItExpr.Is(e => embedVerifier.Verify(e)); + + command.Protected().Verify( + "RespondAsync", + Times.Once(), + msgRef, // text + ItExpr.IsAny(), // embeds + false, // isTTS + ephemeral, // ephemeral + ItExpr.IsAny(), // allowedMentions + ItExpr.IsAny(), // options + ItExpr.IsAny(), // components + embedRef, // embed + ItExpr.IsAny(), // pollProperties + ItExpr.IsAny() // messageFlags + ); + } + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Models/TestChannel.cs b/InstarBot.Tests.Orchestrator/Models/TestChannel.cs new file mode 100644 index 0000000..d5edfc0 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Models/TestChannel.cs @@ -0,0 +1,339 @@ +using Discord; +using InstarBot.Tests; +using Moq; +using PaxAndromeda.Instar; +using System.Diagnostics.CodeAnalysis; +using InvalidOperationException = Amazon.CloudWatchLogs.Model.InvalidOperationException; +using MessageProperties = Discord.MessageProperties; + +#pragma warning disable CS1998 +#pragma warning disable CS8625 + +namespace InstarBot.Test.Framework.Models; + +[SuppressMessage("ReSharper", "ReplaceAutoPropertyWithComputedProperty")] +public sealed class TestChannel : IMockOf, ITextChannel +{ + public ulong Id { get; } + public DateTimeOffset CreatedAt { get; } + + public Mock Mock { get; } = new(); + + private readonly Dictionary _messages = new(); + + public IEnumerable Messages => _messages.Values; + + public TestChannel(Snowflake id) + { + Id = id; + CreatedAt = id.Time; + } + + public void VerifyMessage(string format, bool partial = false) + { + Mock.Verify(c => c.SendMessageAsync( + It.Is(s => TestUtilities.MatchesFormat(s, format, partial)), + false, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )); + } + + public void VerifyEmbed(EmbedVerifier verifier, string format, bool partial = false) + { + Mock.Verify(c => c.SendMessageAsync( + It.Is(n => TestUtilities.MatchesFormat(n, format, partial)), + false, + It.Is(e => verifier.Verify(e)), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )); + } + + + public Task GetMessageAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + { + return Mock.Object.GetMessageAsync(id, mode, options); + } + + public async IAsyncEnumerable> GetMessagesAsync(int limit = 100, + CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + { + yield return _messages.Values.ToList().AsReadOnly(); + } + + public async IAsyncEnumerable> GetMessagesAsync(ulong fromMessageId, Direction dir, + int limit = 100, CacheMode mode = CacheMode.AllowDownload, + RequestOptions options = null) + { + var snowflake = new Snowflake(fromMessageId); + var rz = _messages.Values.Where(n => n.Timestamp.UtcDateTime > snowflake.Time.ToUniversalTime()).ToList().AsReadOnly(); + + yield return rz; + } + + public IAsyncEnumerable> GetMessagesAsync(IMessage fromMessage, Direction dir, + int limit = 100, CacheMode mode = CacheMode.AllowDownload, + RequestOptions options = null) + => GetMessagesAsync(fromMessage.Id, dir, limit, mode, options); + + public Task> GetPinnedMessagesAsync(RequestOptions options = null) + { + return Mock.Object.GetPinnedMessagesAsync(options); + } + + public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) + { + return Mock.Object.DeleteMessageAsync(messageId, options); + } + + public Task DeleteMessageAsync(IMessage message, RequestOptions options = null) + { + return Mock.Object.DeleteMessageAsync(message, options); + } + + public Task ModifyMessageAsync(ulong messageId, Action func, RequestOptions options = null) + { + return Mock.Object.ModifyMessageAsync(messageId, func, options); + } + + public Task TriggerTypingAsync(RequestOptions options = null) + { + return Mock.Object.TriggerTypingAsync(options); + } + + public IDisposable EnterTypingState(RequestOptions options = null) + { + return Mock.Object.EnterTypingState(options); + } + + public string Mention { get; } = null!; + public Task GetCategoryAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + { + return Mock.Object.GetCategoryAsync(mode, options); + } + + public Task SyncPermissionsAsync(RequestOptions options = null) + { + return Mock.Object.SyncPermissionsAsync(options); + } + + public Task CreateInviteAsync(int? maxAge, int? maxUses = null, bool isTemporary = false, bool isUnique = false, + RequestOptions options = null) + { + return Mock.Object.CreateInviteAsync(maxAge, maxUses, isTemporary, isUnique, options); + } + + public Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge, int? maxUses = null, bool isTemporary = false, + bool isUnique = false, RequestOptions options = null) + { + return Mock.Object.CreateInviteToApplicationAsync(applicationId, maxAge, maxUses, isTemporary, isUnique, options); + } + + public Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge, int? maxUses = null, + bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + { + return Mock.Object.CreateInviteToApplicationAsync(application, maxAge, maxUses, isTemporary, isUnique, options); + } + + public Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = null, bool isTemporary = false, + bool isUnique = false, RequestOptions options = null) + { + return Mock.Object.CreateInviteToStreamAsync(user, maxAge, maxUses, isTemporary, isUnique, options); + } + + public Task> GetInvitesAsync(RequestOptions options = null) + { + return Mock.Object.GetInvitesAsync(options); + } + + public ulong? CategoryId { get; } = null; + public Task DeleteMessagesAsync(IEnumerable messages, RequestOptions options = null) + { + return Mock.Object.DeleteMessagesAsync(messages, options); + } + + public Task DeleteMessagesAsync(IEnumerable messageIds, RequestOptions options = null) + { + return Mock.Object.DeleteMessagesAsync(messageIds, options); + } + + public Task ModifyAsync(Action func, RequestOptions options = null) + { + return Mock.Object.ModifyAsync(func, options); + } + + public Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, + IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null) + { + return Mock.Object.CreateThreadAsync(name, type, autoArchiveDuration, message, invitable, slowmode, options); + } + + public Task> GetActiveThreadsAsync(RequestOptions options = null) + { + return Mock.Object.GetActiveThreadsAsync(options); + } + + public bool IsNsfw { get; } = false; + public string Topic { get; } = null!; + public int SlowModeInterval { get; } = 0; + public int DefaultSlowModeInterval => Mock.Object.DefaultSlowModeInterval; + + public ThreadArchiveDuration DefaultArchiveDuration { get; } = default!; + + public TestMessage AddMessage(IGuildUser user, string messageContent) + { + var message = new TestMessage(user, messageContent) + { + Channel = this, + }; + + _messages.Add(message.Id, message); + + return message; + } + + public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + Mock.Object.SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags, poll); + + var msg = new TestMessage(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags, poll); + _messages.Add(msg.Id, msg); + + return Task.FromResult(msg); + } + + public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + return Mock.Object.SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, components, stickers, embeds, flags, poll); + } + + public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + return Mock.Object.SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, components, stickers, embeds, flags, poll); + } + + public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, + PollProperties poll = null) + { + return Mock.Object.SendFileAsync(attachment, text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags, poll); + } + + public Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, + PollProperties poll = null) + { + return Mock.Object.SendFilesAsync(attachments, text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags, poll); + } + + public Task ModifyAsync(Action func, RequestOptions options = null) + { + return ((IGuildChannel)Mock).ModifyAsync(func, options); + } + + public OverwritePermissions? GetPermissionOverwrite(IRole role) + { + return Mock.Object.GetPermissionOverwrite(role); + } + + public OverwritePermissions? GetPermissionOverwrite(IUser user) + { + return Mock.Object.GetPermissionOverwrite(user); + } + + public Task RemovePermissionOverwriteAsync(IRole role, RequestOptions options = null) + { + return Mock.Object.RemovePermissionOverwriteAsync(role, options); + } + + public Task RemovePermissionOverwriteAsync(IUser user, RequestOptions options = null) + { + return Mock.Object.RemovePermissionOverwriteAsync(user, options); + } + + public Task AddPermissionOverwriteAsync(IRole role, OverwritePermissions permissions, RequestOptions options = null) + { + return Mock.Object.AddPermissionOverwriteAsync(role, permissions, options); + } + + public Task AddPermissionOverwriteAsync(IUser user, OverwritePermissions permissions, RequestOptions options = null) + { + return Mock.Object.AddPermissionOverwriteAsync(user, permissions, options); + } + + public IAsyncEnumerable> GetUsersAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + { + return Mock.Object.GetUsersAsync(mode, options); + } + + Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode , RequestOptions options ) + { + return Mock.Object.GetUserAsync(id, mode, options); + } + + public int Position => Mock.Object.Position; + + public ChannelFlags Flags => Mock.Object.Flags; + + public IGuild Guild => Mock.Object.Guild; + + public ulong GuildId => Mock.Object.GuildId; + + public IReadOnlyCollection PermissionOverwrites => Mock.Object.PermissionOverwrites; + + IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode , RequestOptions options ) + { + return ((IChannel)Mock).GetUsersAsync(mode, options); + } + + Task IChannel.GetUserAsync(ulong id, CacheMode mode , RequestOptions options ) + { + return ((IChannel)Mock).GetUserAsync(id, mode, options); + } + + public ChannelType ChannelType => Mock.Object.ChannelType; + + public string Name => Mock.Name; + + public Task DeleteAsync(RequestOptions options = null) + { + return Mock.Object.DeleteAsync(options); + } + + public Task CreateWebhookAsync(string name, Stream avatar = null, RequestOptions options = null) + { + return Mock.Object.CreateWebhookAsync(name, avatar, options); + } + + public Task GetWebhookAsync(ulong id, RequestOptions options = null) + { + return Mock.Object.GetWebhookAsync(id, options); + } + + public Task> GetWebhooksAsync(RequestOptions options = null) + { + return Mock.Object.GetWebhooksAsync(options); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Models/TestGuild.cs b/InstarBot.Tests.Orchestrator/Models/TestGuild.cs new file mode 100644 index 0000000..54edafd --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Models/TestGuild.cs @@ -0,0 +1,42 @@ +using Discord; +using PaxAndromeda.Instar; + +namespace InstarBot.Test.Framework.Models; + +// ReSharper disable once ClassWithVirtualMembersNeverInherited.Global +public class TestGuild : IInstarGuild +{ + public ulong Id { get; init; } + private readonly List _channels = []; + + public IEnumerable TextChannels + { + get => _channels; + init => _channels = value.OfType().ToList(); + } + + + public Dictionary Roles { get; init; } = null!; + + public List Users { get; init; } = []; + + public virtual ITextChannel GetTextChannel(ulong channelId) + { + return TextChannels.First(n => n.Id.Equals(channelId)); + } + + public virtual IRole? GetRole(Snowflake roleId) + { + return Roles.GetValueOrDefault(roleId); + } + + public void AddUser(TestGuildUser user) + { + Users.Add(user); + } + + public void AddChannel(TestChannel testChannel) + { + _channels.Add(testChannel); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestGuildUser.cs b/InstarBot.Tests.Orchestrator/Models/TestGuildUser.cs similarity index 53% rename from InstarBot.Tests.Common/Models/TestGuildUser.cs rename to InstarBot.Tests.Orchestrator/Models/TestGuildUser.cs index fea45a8..6553d12 100644 --- a/InstarBot.Tests.Common/Models/TestGuildUser.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestGuildUser.cs @@ -1,31 +1,32 @@ using System.Diagnostics.CodeAnalysis; using Discord; +using InstarBot.Tests; using Moq; +using PaxAndromeda.Instar; #pragma warning disable CS8625 -namespace InstarBot.Tests.Models; +namespace InstarBot.Test.Framework.Models; [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] [SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] -public class TestGuildUser : IGuildUser +public class TestGuildUser : TestUser, IMockOf, IGuildUser { - private readonly List _roleIds = [ ]; - - public ulong Id { get; init; } - public DateTimeOffset CreatedAt { get; set; } - public string Mention { get; set; } = null!; - public UserStatus Status { get; set; } - public IReadOnlyCollection ActiveClients { get; set; } = null!; - public IReadOnlyCollection Activities { get; set; } = null!; - public string AvatarId { get; set; } = null!; - public string Discriminator { get; set; } = null!; - public ushort DiscriminatorValue { get; set; } - public bool IsBot { get; set; } - public bool IsWebhook { get; set; } - public string Username { get; set; } = null!; - public UserProperties? PublicFlags { get; set; } - public bool IsDeafened { get; set; } + public Mock Mock { get; } = new(); + + private HashSet _roleIds = [ ]; + + public TestGuildUser() : this(Snowflake.Generate(), [ ]) { } + + public TestGuildUser(Snowflake snowflake) : this(snowflake, [ ]) + { } + + public TestGuildUser(Snowflake snowflake, IEnumerable roles) : base(snowflake) + { + _roleIds = roles.Select(n => n.ID).ToHashSet(); + } + + public bool IsDeafened { get; set; } public bool IsMuted { get; set; } public bool IsSelfDeafened { get; set; } public bool IsSelfMuted { get; set; } @@ -36,49 +37,32 @@ public class TestGuildUser : IGuildUser public bool IsVideoing { get; set; } public DateTimeOffset? RequestToSpeakTimestamp { get; set; } - private readonly Mock _dmChannelMock = new(); - - public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) - { - return string.Empty; - } - - public string GetDefaultAvatarUrl() - { - return string.Empty; - } - - public Task CreateDMChannelAsync(RequestOptions options = null!) - { - return Task.FromResult(_dmChannelMock.Object); - } - - public ChannelPermissions GetPermissions(IGuildChannel channel) - { - throw new NotImplementedException(); - } + public ChannelPermissions GetPermissions(IGuildChannel channel) + { + return Mock.Object.GetPermissions(channel); + } - public string GetGuildAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) - { - throw new NotImplementedException(); - } + public string GetGuildAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + { + return Mock.Object.GetGuildAvatarUrl(format, size); + } - public string GetDisplayAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) - { - throw new NotImplementedException(); - } + public string GetGuildBannerUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + { + return Mock.Object.GetGuildBannerUrl(format, size); + } - public Task KickAsync(string reason = null, RequestOptions options = null!) - { - throw new NotImplementedException(); - } + public Task KickAsync(string reason = null, RequestOptions options = null) + { + return Mock.Object.KickAsync(reason, options); + } - public Task ModifyAsync(Action func, RequestOptions options = null) - { - throw new NotImplementedException(); - } + public Task ModifyAsync(Action func, RequestOptions options = null) + { + return Mock.Object.ModifyAsync(func, options); + } - public Task AddRoleAsync(ulong roleId, RequestOptions options = null) + public Task AddRoleAsync(ulong roleId, RequestOptions options = null) { Changed = true; _roleIds.Add(roleId); @@ -95,14 +79,18 @@ public Task AddRoleAsync(IRole role, RequestOptions options = null) public Task AddRolesAsync(IEnumerable roleIds, RequestOptions options = null) { Changed = true; - _roleIds.AddRange(roleIds); + foreach (var id in roleIds) + _roleIds.Add(id); + return Task.CompletedTask; } public Task AddRolesAsync(IEnumerable roles, RequestOptions options = null) { Changed = true; - _roleIds.AddRange(roles.Select(role => role.Id)); + foreach (var role in roles) + _roleIds.Add(role.Id); + return Task.CompletedTask; } @@ -137,22 +125,12 @@ public Task RemoveRolesAsync(IEnumerable roles, RequestOptions options = public Task SetTimeOutAsync(TimeSpan span, RequestOptions options = null) { - throw new NotImplementedException(); + return Mock.Object.SetTimeOutAsync(span, options); } public Task RemoveTimeOutAsync(RequestOptions options = null) { - throw new NotImplementedException(); - } - - public string GetGuildBannerUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) - { - throw new NotImplementedException(); - } - - public string GetAvatarDecorationUrl() - { - throw new NotImplementedException(); + return Mock.Object.RemoveTimeOutAsync(options); } public DateTimeOffset? JoinedAt { get; init; } @@ -162,14 +140,14 @@ public string GetAvatarDecorationUrl() public string GuildAvatarId { get; set; } = null!; public GuildPermissions GuildPermissions { get; set; } public IGuild Guild { get; set; } = null!; - public ulong GuildId => TestUtilities.GuildID; + public ulong GuildId { get; internal set; } = 0; public DateTimeOffset? PremiumSince { get; set; } public IReadOnlyCollection RoleIds - { - get => _roleIds.AsReadOnly(); - init => _roleIds = value.ToList(); - } + { + get => _roleIds.AsReadOnly(); + set => _roleIds = new HashSet(value); + } public bool? IsPending { get; set; } public int Hierarchy { get; set; } @@ -183,15 +161,13 @@ public IReadOnlyCollection RoleIds public string GuildBannerHash { get; set; } = null!; - public string GlobalName { get; set; } = null!; - - public string AvatarDecorationHash { get; set; } = null!; - - public ulong? AvatarDecorationSkuId { get; set; } = null!; - public PrimaryGuild? PrimaryGuild { get; set; } = null!; - - public TestGuildUser Clone() - { + public TestGuildUser Clone() + { return (TestGuildUser) MemberwiseClone(); - } + } + + public void Reset() + { + Changed = false; + } } \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs b/InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs new file mode 100644 index 0000000..1481f5a --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs @@ -0,0 +1,71 @@ +using Discord; +using FluentAssertions; +using Moq; +using PaxAndromeda.Instar; +using System; +using System.Threading; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Test.Framework.Models; + +public class TestInteractionContext : InstarContext, IMockOf +{ + private readonly TestOrchestrator _orchestrator; + public Mock Mock { get; } = new(); + + public IDiscordClient Client => Mock.Object.Client; + + public TestInteractionContext(TestOrchestrator orchestrator, Snowflake actorId, Snowflake channelId) + { + _orchestrator = orchestrator; + + var discordService = orchestrator.GetService(); + if (discordService.GetUser(actorId) is not { } actor) + throw new InvalidOperationException("Actor needs to be registered before creating an interaction context"); + + + Mock.SetupGet(static n => n.User!) + .Returns(orchestrator.GetService().GetUser(actorId) + ?? throw new InvalidOperationException("Failed to mock interaction context correctly: missing actor")); + + Mock.SetupGet(static n => n.Channel!) + .Returns(orchestrator.GetService().GetChannel(channelId).Result as TestChannel + ?? throw new InvalidOperationException("Failed to mock interaction context correctly: missing channel")); + + Mock.SetupGet(static n => n.Guild).Returns(orchestrator.GetService().GetGuild()); + } + + private Mock SetupGuildMock() + { + var discordService = _orchestrator.GetService(); + + var guildMock = new Mock(); + guildMock.Setup(n => n.Id).Returns(_orchestrator.GuildID); + guildMock.Setup(n => n.GetTextChannel(It.IsAny())) + .Returns((ulong x) => discordService.GetChannel(x) as ITextChannel); + + return guildMock; + } + + private TestGuildUser SetupUser(IGuildUser user) + { + return new TestGuildUser(user.Id, user.RoleIds.Select(id => new Snowflake(id))); + } + + private TestChannel SetupChannel(Snowflake channelId) + { + var discordService = _orchestrator.GetService(); + var channel = discordService.GetChannel(channelId).Result; + + if (channel is not TestChannel testChannel) + throw new InvalidOperationException("Channel must be registered before use in an interaction context"); + + return testChannel; + } + + + protected internal override IInstarGuild Guild => Mock.Object.Guild; + protected internal override IGuildChannel Channel => Mock.Object.Channel; + protected internal override IGuildUser User => Mock.Object.User; + public IDiscordInteraction Interaction { get; } = new Mock().Object; +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Models/TestMessage.cs b/InstarBot.Tests.Orchestrator/Models/TestMessage.cs new file mode 100644 index 0000000..71c82ed --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Models/TestMessage.cs @@ -0,0 +1,155 @@ +using Discord; +using Moq; +using PaxAndromeda.Instar; +using MessageProperties = Discord.MessageProperties; + +namespace InstarBot.Test.Framework.Models; + +public sealed class TestMessage : IMockOf, IUserMessage, IMessage +{ + public Mock Mock { get; } = new(); + + internal TestMessage(IUser user, string message) + { + Id = Snowflake.Generate(); + CreatedAt = DateTimeOffset.Now; + Timestamp = DateTimeOffset.Now; + Author = user; + + Content = message; + } + + public TestMessage(string text, bool isTTS, Embed? embed, RequestOptions? options, AllowedMentions? allowedMentions, MessageReference? messageReference, MessageComponent? components, ISticker[]? stickers, Embed[]? embeds, MessageFlags? flags, PollProperties? poll) + { + Id = Snowflake.Generate(); + Content = text; + IsTTS = isTTS; + Flags = flags; + + var embedList = new List(); + + if (embed is not null) + embedList.Add(embed); + if (embeds is not null) + embedList.AddRange(embeds); + + Flags = flags; + Reference = messageReference; + } + + public ulong Id { get; } + public DateTimeOffset CreatedAt { get; } + + public Task AddReactionAsync(IEmote emote, RequestOptions? options = null) + { + return Mock.Object.AddReactionAsync(emote, options); + } + + public Task RemoveReactionAsync(IEmote emote, IUser user, RequestOptions? options = null) + { + return Mock.Object.RemoveReactionAsync(emote, user, options); + } + + public Task RemoveReactionAsync(IEmote emote, ulong userId, RequestOptions? options = null) + { + return Mock.Object.RemoveReactionAsync(emote, userId, options); + } + + public Task RemoveAllReactionsAsync(RequestOptions? options = null) + { + return Mock.Object.RemoveAllReactionsAsync(options); + } + + public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions? options = null) + { + return Mock.Object.RemoveAllReactionsForEmoteAsync(emote, options); + } + + public IAsyncEnumerable> GetReactionUsersAsync(IEmote emoji, int limit, RequestOptions? options = null, + ReactionType type = ReactionType.Normal) + { + return Mock.Object.GetReactionUsersAsync(emoji, limit, options, type); + } + + public MessageType Type => default; + public MessageSource Source => default; + public bool IsTTS { get; set; } + + public bool IsPinned => false; + public bool IsSuppressed => false; + public bool MentionedEveryone => false; + public string Content { get; } + public string CleanContent => null!; + public DateTimeOffset Timestamp { get; } + public DateTimeOffset? EditedTimestamp => null; + public IMessageChannel Channel { get; set; } = null!; + public IUser Author { get; } + public IThreadChannel Thread => null!; + public IReadOnlyCollection Attachments => null!; + public IReadOnlyCollection Embeds => null!; + public IReadOnlyCollection Tags => null!; + public IReadOnlyCollection MentionedChannelIds => null!; + public IReadOnlyCollection MentionedRoleIds => null!; + public IReadOnlyCollection MentionedUserIds => null!; + public MessageActivity Activity => null!; + public MessageApplication Application => null!; + public MessageReference Reference { get; set; } + + public IReadOnlyDictionary Reactions => null!; + public IReadOnlyCollection Components => null!; + public IReadOnlyCollection Stickers => null!; + public MessageFlags? Flags { get; set; } + + public IMessageInteraction Interaction => null!; + public MessageRoleSubscriptionData RoleSubscriptionData => null!; + public PurchaseNotification PurchaseNotification => Mock.Object.PurchaseNotification; + + public MessageCallData? CallData => Mock.Object.CallData; + + public Task ModifyAsync(Action func, RequestOptions? options = null) + { + return Mock.Object.ModifyAsync(func, options); + } + + public Task PinAsync(RequestOptions? options = null) + { + return Mock.Object.PinAsync(options); + } + + public Task UnpinAsync(RequestOptions? options = null) + { + return Mock.Object.UnpinAsync(options); + } + + public Task CrosspostAsync(RequestOptions? options = null) + { + return Mock.Object.CrosspostAsync(options); + } + + public string Resolve(TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name, TagHandling roleHandling = TagHandling.Name, + TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name) + { + return Mock.Object.Resolve(userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling); + } + + public Task EndPollAsync(RequestOptions options) + { + return Mock.Object.EndPollAsync(options); + } + + public IAsyncEnumerable> GetPollAnswerVotersAsync(uint answerId, int? limit = null, ulong? afterId = null, + RequestOptions? options = null) + { + return Mock.Object.GetPollAnswerVotersAsync(answerId, limit, afterId, options); + } + + public MessageResolvedData ResolvedData { get; set; } + public IUserMessage ReferencedMessage { get; set; } + public IMessageInteractionMetadata InteractionMetadata { get; set; } + public IReadOnlyCollection ForwardedMessages { get; set; } + public Poll? Poll { get; set; } + public Task DeleteAsync(RequestOptions? options = null) + { + return Mock.Object.DeleteAsync(options); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestRole.cs b/InstarBot.Tests.Orchestrator/Models/TestRole.cs similarity index 63% rename from InstarBot.Tests.Common/Models/TestRole.cs rename to InstarBot.Tests.Orchestrator/Models/TestRole.cs index ae71435..e9f8eed 100644 --- a/InstarBot.Tests.Common/Models/TestRole.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestRole.cs @@ -1,14 +1,17 @@ using System.Diagnostics.CodeAnalysis; using Discord; +using Moq; using PaxAndromeda.Instar; #pragma warning disable CS8625 -namespace InstarBot.Tests.Models; +namespace InstarBot.Test.Framework.Models; [SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] -public sealed class TestRole : IRole +public sealed class TestRole : IMockOf, IRole { + public Mock Mock { get; } = new(); + internal TestRole(Snowflake snowflake) { Id = snowflake; @@ -18,29 +21,20 @@ internal TestRole(Snowflake snowflake) public ulong Id { get; set; } public DateTimeOffset CreatedAt { get; set; } - public Task DeleteAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - public string Mention { get; set; } = null!; - public int CompareTo(IRole? other) + public int CompareTo(IRole? other) { - throw new NotImplementedException(); - } + return Comparer.Default.Compare(this, other); + } - public Task ModifyAsync(Action func, RequestOptions options = null) - { - throw new NotImplementedException(); - } + public Task DeleteAsync(RequestOptions options = null) => Mock.Object.DeleteAsync(options); - public string GetIconUrl() - { - throw new NotImplementedException(); - } + public Task ModifyAsync(Action func, RequestOptions options = null) => Mock.Object.ModifyAsync(func, options); + + public string GetIconUrl() => Mock.Object.GetIconUrl(); - public IGuild Guild { get; set; } = null!; + public IGuild Guild { get; set; } = null!; public Color Color { get; set; } = default!; public bool IsHoisted { get; set; } = false; public bool IsManaged { get; set; } = false; diff --git a/InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs b/InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs new file mode 100644 index 0000000..377aa2f --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs @@ -0,0 +1,84 @@ +using Discord; +using Discord.WebSocket; +using Moq; + +namespace InstarBot.Test.Framework.Models; + +public class TestSocketUser : IMockOf +{ + public Mock Mock { get; } = new(); + + + public bool IsBot + { + get => Mock.Object.IsBot; + set => Mock.Setup(obj => obj.IsBot).Returns(value); + } + + public string Username + { + get => Mock.Object.Username; + set => Mock.Setup(obj => obj.Username).Returns(value); + } + + public ushort DiscriminatorValue + { + get => Mock.Object.DiscriminatorValue; + set => Mock.Setup(obj => obj.DiscriminatorValue).Returns(value); + } + + public string AvatarId + { + get => Mock.Object.AvatarId; + set => Mock.Setup(obj => obj.AvatarId).Returns(value); + } + + public bool IsWebhook + { + get => Mock.Object.IsWebhook; + set => Mock.Setup(obj => obj.IsWebhook).Returns(value); + } + + public string GlobalName + { + get => Mock.Object.GlobalName; + set => Mock.Setup(obj => obj.GlobalName).Returns(value); + } + + public string AvatarDecorationHash + { + get => Mock.Object.AvatarDecorationHash; + set => Mock.Setup(obj => obj.AvatarDecorationHash).Returns(value); + } + + public ulong? AvatarDecorationSkuId + { + get => Mock.Object.AvatarDecorationSkuId; + set => Mock.Setup(obj => obj.AvatarDecorationSkuId).Returns(value); + } + + public PrimaryGuild? PrimaryGuild + { + get => Mock.Object.PrimaryGuild; + set => Mock.Setup(obj => obj.PrimaryGuild).Returns(value); + } + + public static TestSocketUser FromUser(IUser? user) + { + if (user is null) + throw new ArgumentNullException(nameof(user)); + + return new TestSocketUser + { + IsBot = user.IsBot, + Username = user.Username, + DiscriminatorValue = user.DiscriminatorValue, + AvatarId = user.AvatarId, + IsWebhook = user.IsWebhook, + GlobalName = user.GlobalName, + AvatarDecorationHash = user.AvatarDecorationHash, + AvatarDecorationSkuId = user.AvatarDecorationSkuId, + PrimaryGuild = user.PrimaryGuild + }; + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestUser.cs b/InstarBot.Tests.Orchestrator/Models/TestUser.cs similarity index 77% rename from InstarBot.Tests.Common/Models/TestUser.cs rename to InstarBot.Tests.Orchestrator/Models/TestUser.cs index c815a9d..3678d2f 100644 --- a/InstarBot.Tests.Common/Models/TestUser.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestUser.cs @@ -1,13 +1,18 @@ -using Discord; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; +using Discord; +using Moq; using PaxAndromeda.Instar; -namespace InstarBot.Tests.Models; +namespace InstarBot.Test.Framework.Models; [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] [SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] -public class TestUser : IUser +public class TestUser : IMockOf, IUser { + public Mock DMChannelMock { get; } = new(); + + public Mock Mock { get; } = new(); + public TestUser(Snowflake snowflake) { Id = snowflake; @@ -45,27 +50,27 @@ public TestUser(TestGuildUser guildUser) public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) { - return string.Empty; + return Mock.Object.GetAvatarUrl(format, size); } public string GetDefaultAvatarUrl() { - return string.Empty; + return Mock.Object.GetDefaultAvatarUrl(); } public string GetDisplayAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) { - throw new NotImplementedException(); + return Mock.Object.GetDisplayAvatarUrl(format, size); } - public Task CreateDMChannelAsync(RequestOptions? options = null) + public Task CreateDMChannelAsync(RequestOptions options = null) { - throw new NotImplementedException(); + return Task.FromResult(DMChannelMock.Object); } public string GetAvatarDecorationUrl() { - return string.Empty; + return Mock.Object.GetAvatarDecorationUrl(); } public string AvatarId { get; set; } diff --git a/InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs b/InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs new file mode 100644 index 0000000..d08c8b5 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs @@ -0,0 +1,27 @@ +using Discord; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Test.Framework.Services; + +public class TestAutoMemberSystem : IMockOf, IAutoMemberSystem +{ + public Mock Mock { get; } = new(); + + public Task Start() + { + return Mock.Object.Start(); + } + + public Task RunAsync() + { + return Mock.Object.RunAsync(); + } + + public MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IGuildUser user) + { + return Mock.Object.CheckEligibility(cfg, user); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs b/InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs new file mode 100644 index 0000000..4763b93 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs @@ -0,0 +1,23 @@ +using Discord; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Test.Framework.Services; + +public class TestBirthdaySystem : IBirthdaySystem +{ + public Task Start() + { + return Task.CompletedTask; + } + + public Task RunAsync() + { + return Task.CompletedTask; + } + + public Task GrantUnexpectedBirthday(IGuildUser user, Birthday birthday) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs b/InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs new file mode 100644 index 0000000..63fd6b2 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs @@ -0,0 +1,131 @@ +using System.Diagnostics.CodeAnalysis; +using Amazon.DynamoDBv2.DataModel; +using Discord; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Test.Framework.Services; + +public class TestDatabaseService : IMockOf, IDatabaseService +{ + private readonly TimeProvider _timeProvider; + private readonly Dictionary _userDataTable; + private readonly Dictionary _notifications; + private readonly Mock _contextMock; + + // Although we don't mock anything here, we use this to + // throw exceptions if they're configured. + public Mock Mock { get; } = new(); + + public TestDatabaseService(TimeProvider timeProvider) + { + _timeProvider = timeProvider; + _userDataTable = new Dictionary(); + _notifications = []; + _contextMock = new Mock(); + + SetupContextMock(_userDataTable, data => data.UserID!); + SetupContextMock(_notifications, notif => notif.Date); + } + + private void SetupContextMock(Dictionary mapPointer, Func keySelector) + { + _contextMock.Setup(n => n.DeleteAsync(It.IsAny())).Callback((T data, CancellationToken _) => + { + var key = keySelector(data); + mapPointer.Remove(key); + }); + + _contextMock.Setup(n => n.SaveAsync(It.IsAny())).Callback((T data, CancellationToken _) => + { + var key = keySelector(data); + mapPointer[key] = data; + }); + } + + public Task?> GetUserAsync(Snowflake snowflake) + { + Mock.Object.GetUserAsync(snowflake); + + return !_userDataTable.TryGetValue(snowflake, out var userData) + ? Task.FromResult>(null) + : Task.FromResult(new InstarDatabaseEntry(_contextMock.Object, userData)); + } + + public Task> GetOrCreateUserAsync(IGuildUser user) + { + Mock.Object.GetOrCreateUserAsync(user); + + if (!_userDataTable.TryGetValue(user.Id, out var userData)) + { + userData = InstarUserData.CreateFrom(user); + _userDataTable[user.Id] = userData; + } + + return Task.FromResult(new InstarDatabaseEntry(_contextMock.Object, userData)); + } + + [SuppressMessage("ReSharper", "PossibleMultipleEnumeration", Justification = "Doesn't actually enumerate multiple times. First 'enumeration' is a mock which does nothing.")] + public Task>> GetBatchUsersAsync(IEnumerable snowflakes) + { + Mock.Object.GetBatchUsersAsync(snowflakes); + + var returnList = new List(); + foreach (Snowflake snowflake in snowflakes) + { + if (_userDataTable.TryGetValue(snowflake, out var userData)) + returnList.Add(userData); + } + + return Task.FromResult(returnList.Select(data => new InstarDatabaseEntry(_contextMock.Object, data)).ToList()); + } + + public Task CreateUserAsync(InstarUserData data) + { + Mock.Object.CreateUserAsync(data); + + _userDataTable[data.UserID!] = data; + return Task.CompletedTask; + } + + public Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness) + { + Mock.Object.GetUsersByBirthday(birthdate, fuzziness); + + var startUtc = birthdate.ToUniversalTime() - fuzziness; + var endUtc = birthdate.ToUniversalTime() + fuzziness; + + var matchedUsers = _userDataTable.Values.Where(userData => + { + if (userData.Birthday == null) + return false; + var userBirthdateThisYear = userData.Birthday.Observed.ToUniversalTime(); + return userBirthdateThisYear >= startUtc && userBirthdateThisYear <= endUtc; + }).ToList(); + + return Task.FromResult(matchedUsers.Select(data => new InstarDatabaseEntry(_contextMock.Object, data)).ToList()); + } + + public Task>> GetPendingNotifications() + { + Mock.Object.GetPendingNotifications(); + + var currentTimeUtc = _timeProvider.GetUtcNow(); + + var pendingNotifications = _notifications.Values + .Where(notification => notification.Date <= currentTimeUtc) + .ToList(); + + return Task.FromResult(pendingNotifications.Select(data => new InstarDatabaseEntry(_contextMock.Object, data)).ToList()); + } + + public Task> CreateNotificationAsync(Notification notification) + { + Mock.Object.CreateNotificationAsync(notification); + + _notifications[notification.Date] = notification; + return Task.FromResult(new InstarDatabaseEntry(_contextMock.Object, notification)); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs b/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs new file mode 100644 index 0000000..29d13e6 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs @@ -0,0 +1,143 @@ +using Discord; +using InstarBot.Test.Framework.Models; +using JetBrains.Annotations; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Modals; +using PaxAndromeda.Instar.Services; +using System.Threading.Channels; + +namespace InstarBot.Test.Framework.Services; + +public class TestDiscordService : IDiscordService +{ + private readonly AsyncEvent _userJoinedEvent = new(); + private readonly AsyncEvent _userLeftEvent = new(); + private readonly AsyncEvent _userUpdatedEvent = new(); + private readonly AsyncEvent _messageReceivedEvent = new(); + private readonly AsyncEvent _messageDeletedEvent = new(); + + public event Func UserJoined + { + add => _userJoinedEvent.Add(value); + remove => _userJoinedEvent.Remove(value); + } + + public event Func UserLeft + { + add => _userLeftEvent.Add(value); + remove => _userLeftEvent.Remove(value); + } + + public event Func UserUpdated + { + add => _userUpdatedEvent.Add(value); + remove => _userUpdatedEvent.Remove(value); + } + + public event Func MessageReceived + { + add => _messageReceivedEvent.Add(value); + remove => _messageReceivedEvent.Remove(value); + } + + public event Func MessageDeleted + { + add => _messageDeletedEvent.Add(value); + remove => _messageDeletedEvent.Remove(value); + } + + private readonly TestGuild _guild; + + public TestDiscordService(TestDiscordContext context) + { + var users = context.Users.Select(IGuildUser (n) => n).ToList(); + var channels = context.Channels.Select(ITextChannel (n) => n); + var roles = context.Roles.ToDictionary(n => new Snowflake(n.Id), IRole (n) => n); + + _guild = new TestGuild + { + Id = context.GuildId, + Roles = roles, + TextChannels = channels, + Users = users + }; + } + + public Task Start(IServiceProvider provider) => Task.CompletedTask; + + public IInstarGuild GetGuild() + { + return _guild; + } + + public Task> GetAllUsers() + { + return Task.FromResult(_guild.Users.AsEnumerable()); + } + + public Task GetChannel(Snowflake channelId) + { + return Task.FromResult(_guild.GetTextChannel(channelId)); + } + + public IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime) + { + var result = from channel in guild.TextChannels.OfType() + from message in channel.Messages + where message.Timestamp > afterTime + select message; + + return result.ToAsyncEnumerable(); + } + + public IGuildUser? GetUser(Snowflake snowflake) + { + return _guild.Users.FirstOrDefault(n => n.Id == snowflake.ID); + } + + public IEnumerable GetAllUsersWithRole(Snowflake roleId) + { + return _guild.Users.Where(n => n.RoleIds.Contains(roleId)); + } + + public Task SyncUsers() + { + return Task.CompletedTask; + } + + public void CreateChannel(Snowflake channelId) + { + _guild.AddChannel(new TestChannel(channelId)); + } + + public TestGuildUser CreateUser(Snowflake userId) + { + return CreateUser(new TestGuildUser(userId) + { + GuildId = GetGuild().Id + }); + } + + public TestGuildUser CreateUser(TestGuildUser user) + { + user.GuildId = GetGuild().Id; + _guild.AddUser(user); + return user; + } + + public async Task TriggerUserJoined(IGuildUser user) + { + await _userJoinedEvent.Invoke(user); + } + + public async Task TriggerUserUpdated(UserUpdatedEventArgs args) + { + await _userUpdatedEvent.Invoke(args); + } + + [UsedImplicitly] + public async Task TriggerMessageReceived(IMessage message) + { + await _messageReceivedEvent.Invoke(message); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Services/TestDynamicConfigService.cs b/InstarBot.Tests.Orchestrator/Services/TestDynamicConfigService.cs new file mode 100644 index 0000000..12e061d --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Services/TestDynamicConfigService.cs @@ -0,0 +1,43 @@ +using JetBrains.Annotations; +using Newtonsoft.Json; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Test.Framework.Services; + +public sealed class TestDynamicConfigService : IDynamicConfigService +{ + private readonly string _configPath; + private readonly Dictionary _parameters = new(); + private InstarDynamicConfiguration _config = null!; + + public TestDynamicConfigService(string configPath) + { + _configPath = configPath; + + Task.Run(Initialize).Wait(); + } + + [UsedImplicitly] + public TestDynamicConfigService(string configPath, Dictionary parameters) + : this(configPath) + { + _parameters = parameters; + } + + public Task GetConfig() + { + return Task.FromResult(_config); + } + + public Task GetParameter(string parameterName) + { + return Task.FromResult(_parameters[parameterName])!; + } + + public async Task Initialize() + { + var data = await File.ReadAllTextAsync(_configPath); + _config = JsonConvert.DeserializeObject(data)!; + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs b/InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs new file mode 100644 index 0000000..d45d3f3 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs @@ -0,0 +1,95 @@ +using InstarBot.Test.Framework.Models; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Gaius; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Test.Framework.Services; + +public sealed class TestGaiusAPIService : IGaiusAPIService +{ + private readonly Dictionary> _warnings; + private readonly Dictionary> _caselogs; + private bool _inhibit; + + public void Inhibit() + { + _inhibit = true; + } + + public void AddWarning(TestGuildUser user, Warning warning) + { + if (!_warnings.ContainsKey(user.Id)) + _warnings[user.Id] = []; + + _warnings[user.Id].Add(warning); + } + + public void AddCaselog(TestGuildUser user, Caselog caselog) + { + if (!_caselogs.ContainsKey(user.Id)) + _caselogs[user.Id] = [ ]; + + _caselogs[user.Id].Add(caselog); + } + + public TestGaiusAPIService() : + this(false) { } + + public TestGaiusAPIService(bool inhibit) + : this(new Dictionary>(), new Dictionary>(), inhibit) + { } + + public TestGaiusAPIService(Dictionary> warnings, + Dictionary> caselogs, + bool inhibit = false) + { + _warnings = warnings; + _caselogs = caselogs; + _inhibit = inhibit; + } + + public void Dispose() + { + // do nothing + } + + public Task> GetAllWarnings() + { + return Task.FromResult>(_warnings.Values.SelectMany(list => list).ToList()); + } + + public Task> GetAllCaselogs() + { + return Task.FromResult>(_caselogs.Values.SelectMany(list => list).ToList()); + } + + public Task> GetWarningsAfter(DateTime dt) + { + return Task.FromResult>(from list in _warnings.Values from item in list where item.WarnDate > dt select item); + } + + public Task> GetCaselogsAfter(DateTime dt) + { + return Task.FromResult>(from list in _caselogs.Values from item in list where item.Date > dt select item); + } + + public Task?> GetWarnings(Snowflake userId) + { + if (_inhibit) + return Task.FromResult?>(null); + + return !_warnings.TryGetValue(userId, out var warning) + ? Task.FromResult?>([]) + : Task.FromResult?>(warning); + } + + public Task?> GetCaselogs(Snowflake userId) + { + if (_inhibit) + return Task.FromResult?>(null); + + return !_caselogs.TryGetValue(userId, out var caselog) + ? Task.FromResult?>([]) + : Task.FromResult?>(caselog); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockMetricService.cs b/InstarBot.Tests.Orchestrator/Services/TestMetricService.cs similarity index 67% rename from InstarBot.Tests.Common/Services/MockMetricService.cs rename to InstarBot.Tests.Orchestrator/Services/TestMetricService.cs index 4a9c115..5625acb 100644 --- a/InstarBot.Tests.Common/Services/MockMetricService.cs +++ b/InstarBot.Tests.Orchestrator/Services/TestMetricService.cs @@ -2,9 +2,9 @@ using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; -namespace InstarBot.Tests.Services; +namespace InstarBot.Test.Framework.Services; -public sealed class MockMetricService : IMetricService +public sealed class TestMetricService : IMetricService { private readonly List<(Metric, double)> _emittedMetrics = []; @@ -14,6 +14,12 @@ public Task Emit(Metric metric, double value) return Task.FromResult(true); } + public Task Emit(Metric metric, double value, Dictionary dimensions) + { + _emittedMetrics.Add((metric, value)); + return Task.FromResult(true); + } + [UsedImplicitly] public IEnumerable GetMetricValues(Metric metric) { diff --git a/InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs b/InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs new file mode 100644 index 0000000..7f87c4b --- /dev/null +++ b/InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs @@ -0,0 +1,41 @@ +using Discord; +using InstarBot.Test.Framework.Models; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.DynamoModels; + +namespace InstarBot.Test.Framework; + +public class TestDatabaseContext +{ +} + +public class TestDatabaseContextBuilder +{ + private readonly TestDiscordContextBuilder? _discordContextBuilder; + + private Dictionary _registeredUsers = new(); + + public TestDatabaseContextBuilder(ref TestDiscordContextBuilder? discordContextBuilder) + { + _discordContextBuilder = discordContextBuilder; + } + + public TestDatabaseContextBuilder RegisterUser(Snowflake userId) + => RegisterUser(userId, x => x); + + public TestDatabaseContextBuilder RegisterUser(Snowflake userId, Func editExpr) + { + if (_registeredUsers.TryGetValue(userId, out InstarUserData? userData)) + { + _registeredUsers[userId] = editExpr(userData); + return this; + } + + if (_discordContextBuilder is null || !_discordContextBuilder.TryGetUser(userId, out TestGuildUser guildUser)) + throw new InvalidOperationException($"You must register {userId.ID} as a Discord user before calling this method."); + + _registeredUsers[userId] = editExpr(InstarUserData.CreateFrom(guildUser)); + + return this; + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs b/InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs new file mode 100644 index 0000000..934370a --- /dev/null +++ b/InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs @@ -0,0 +1,134 @@ +using System.Collections.ObjectModel; +using Discord; +using InstarBot.Test.Framework.Models; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Test.Framework; + +public class TestDiscordContext +{ + public Snowflake GuildId { get; } + public ReadOnlyCollection Users { get; } + public ReadOnlyCollection Channels { get; } + public ReadOnlyCollection Roles { get; } + + public TestDiscordContext(Snowflake guildId, IEnumerable users, IEnumerable channels, IEnumerable roles) + { + GuildId = guildId; + Users = users.ToList().AsReadOnly(); + Channels = channels.ToList().AsReadOnly(); + Roles = roles.ToList().AsReadOnly(); + } + + public static TestDiscordContextBuilder Builder => new(); +} + +public class TestDiscordContextBuilder : IBuilder +{ + private Snowflake _guildId = Snowflake.Generate(); + private readonly Dictionary _registeredUsers = new(); + private readonly Dictionary _registeredChannels = new(); + private readonly Dictionary _registeredRoles = new(); + + public TestDiscordContext Build() + { + return new TestDiscordContext(_guildId, _registeredUsers.Values, _registeredChannels.Values, _registeredRoles.Values); + } + + public async Task LoadFromConfig(IDynamicConfigService configService) + { + var cfg = await configService.GetConfig(); + + _guildId = cfg.TargetGuild; + RegisterUser(cfg.BotUserID, cfg.BotName); + + RegisterChannel(cfg.TargetChannel); + RegisterChannel(cfg.StaffAnnounceChannel); + + RegisterRole(cfg.StaffRoleID, "Staff"); + RegisterRole(cfg.NewMemberRoleID, "New Member"); + RegisterRole(cfg.MemberRoleID, "Member"); + + foreach (Snowflake snowflake in cfg.AuthorizedStaffID) + RegisterRole(snowflake); + + LoadFromAutoMemberConfig(cfg.AutoMemberConfig); + LoadFromBirthdayConfig(cfg.BirthdayConfig); + LoadFromTeamsConfig(cfg.Teams); + + return this; + } + + private void LoadFromAutoMemberConfig(AutoMemberConfig cfg) + { + RegisterRole(cfg.HoldRole, "AMH"); + RegisterChannel(cfg.IntroductionChannel); + + foreach (Snowflake roleId in cfg.RequiredRoles.SelectMany(n => n.Roles)) + RegisterRole(roleId); // no names here sadly + } + + private void LoadFromBirthdayConfig(BirthdayConfig cfg) + { + RegisterRole(cfg.BirthdayRole, "Happy Birthday!"); + RegisterChannel(cfg.BirthdayAnnounceChannel); + + foreach (Snowflake snowflake in cfg.AgeRoleMap.Select(n => n.Role)) + RegisterRole(snowflake); + } + + private void LoadFromTeamsConfig(IEnumerable teams) + { + foreach (Team team in teams) + { + RegisterRole(team.ID); + RegisterUser(team.Teamleader); + } + } + + private void RegisterChannel(Snowflake snowflake) + { + if (_registeredChannels.ContainsKey(snowflake)) + return; + + _registeredChannels.Add(snowflake, new TestChannel(snowflake)); + } + + private void RegisterRole(Snowflake snowflake, string name = "Role") + { + if (_registeredRoles.ContainsKey(snowflake)) + return; + + _registeredRoles.Add(snowflake, new TestRole(snowflake) + { + Name = name + }); + } + + private void RegisterUser(Snowflake snowflake, string name = "User") + { + if (_registeredUsers.ContainsKey(snowflake)) + return; + + _registeredUsers.Add(snowflake, new TestGuildUser(snowflake) + { + GlobalName = name, + DisplayName = name, + Username = name, + Nickname = name + }); + } + + public TestDiscordContextBuilder RegisterUser(TestGuildUser user) + { + _registeredUsers.Add(user.Id, user); + return this; + } + + internal bool TryGetUser(Snowflake userId, out TestGuildUser testGuildUser) + { + return _registeredUsers.TryGetValue(userId, out testGuildUser); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/TestOrchestrator.cs b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs new file mode 100644 index 0000000..448eee3 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs @@ -0,0 +1,226 @@ +using Discord; +using InstarBot.Test.Framework.Models; +using InstarBot.Test.Framework.Services; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Services; +using Serilog; +using Serilog.Events; +using System.Diagnostics.CodeAnalysis; +using System.Reactive.Subjects; +using System.Runtime.InteropServices; +using static Microsoft.ApplicationInsights.MetricDimensionNames.TelemetryContext; +using BindingFlags = System.Reflection.BindingFlags; +using InvalidOperationException = Amazon.CloudWatchLogs.Model.InvalidOperationException; + +namespace InstarBot.Test.Framework +{ + [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] + public class TestOrchestrator + { + private readonly IServiceProvider _serviceProvider; + private readonly Snowflake _actor; + public static TestServiceProviderBuilder Builder => new(); + + public IGuildUser Actor { + get + { + var user = GetUser(_actor); + + if (user is null) + { + if (GetService() is not TestDiscordService tds) + throw new InvalidOperationException("Discord service was not mocked correctly."); + + tds.CreateUser(_actor); + user = GetUser(_actor); + } + + return user!; + } + } + + public TestGuildUser Subject { get; set; } + + public Snowflake GuildID => GetService().GetGuild().Id; + + // Shortcuts for common services + public IDatabaseService Database => GetService(); + public IDiscordService Discord => GetService(); + public IDynamicConfigService DynamicConfigService => GetService(); + public TimeProvider TimeProvider => GetService(); + + public InstarDynamicConfiguration Configuration => DynamicConfigService.GetConfig().Result; + public TestMetricService Metrics => (TestMetricService) GetService(); + public TestGuild Guild => (TestGuild) Discord.GetGuild(); + + public IServiceProvider ServiceProvider => _serviceProvider; + + internal TestOrchestrator(IServiceProvider serviceProvider, Snowflake actor) + { + SetupLogging(); + + _serviceProvider = serviceProvider; + _actor = actor; + + InitializeActor(); + InitializeSubject(CreateUser()); + } + + internal TestOrchestrator(IServiceProvider serviceProvider, Snowflake actor, TestGuildUser subject) + { + SetupLogging(); + + _serviceProvider = serviceProvider; + _actor = actor; + + InitializeActor(); + InitializeSubject(subject); + + // We need to make sure the user is also in the DiscordService + if (Discord.GetGuild() is not TestGuild tg) + throw new InvalidOperationException("Discord service was not mocked correctly."); + tg.AddUser(subject); + } + + private void InitializeSubject(TestGuildUser subject) + { + subject.GuildId = GuildID; + Subject = subject; + + if (GetService() is not TestDatabaseService tdbs) + throw new InvalidOperationException("Database service was not mocked correctly."); + + tdbs.CreateUserAsync(InstarUserData.CreateFrom(Subject)).Wait(); + } + + private void InitializeActor() + { + if (GetService() is not TestDiscordService tds) + throw new InvalidOperationException("Discord service was not mocked correctly."); + + if (tds.GetUser(_actor) is not { } user) + user = tds.CreateUser(_actor); + + if (GetService() is not TestDatabaseService tdbs) + throw new InvalidOperationException("Database service was not mocked correctly."); + + tdbs.CreateUserAsync(InstarUserData.CreateFrom(user)).Wait(); + } + + private static void SetupLogging() + { + Log.Logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.Is(LogEventLevel.Verbose) + .WriteTo.Console() + .CreateLogger(); + Log.Warning("Logging is enabled for this unit test."); + } + + public T GetService() where T : class { + return _serviceProvider.GetRequiredService(); + } + + public IGuildUser? GetUser(Snowflake snowflake) + { + var discordService = _serviceProvider.GetRequiredService(); + + return discordService.GetUser(snowflake); + } + + public Mock GetCommand(Func constructor) where T : BaseCommand + { + var constructors = typeof(T).GetConstructors(); + + var mock = new Mock(() => constructor()); + var executionChannel = Snowflake.Generate(); + + if (Discord is not TestDiscordService tds) + throw new InvalidOperationException("Discord service is not an instance of TestDiscordService"); + + tds.CreateChannel(executionChannel); + + mock.SetupGet(obj => obj.Context).Returns(new TestInteractionContext(this, _actor, executionChannel)); + + return mock; + } + + public Mock GetCommand() where T : BaseCommand + { + var constructors = typeof(T).GetConstructors(); + + // Sift through the constructors to find one that works + foreach (var constructor in constructors) + { + var parameters = constructor.GetParameters().Select(n => n.ParameterType).Select(ty => _serviceProvider.GetService(ty)).ToList(); + + if (!parameters.All(obj => obj is not null)) + continue; + + var executionChannel = Snowflake.Generate(); + + if (Discord is not TestDiscordService tds) + throw new InvalidOperationException("Discord service is not an instance of TestDiscordService"); + + tds.CreateChannel(executionChannel); + + var mock = new Mock(parameters.ToArray()!); + + mock.SetupGet(obj => obj.Context).Returns(new TestInteractionContext(this, _actor, executionChannel)); + + return mock; + } + + throw new InvalidOperationException($"Failed to find a suitable constructor for {typeof(T).FullName}!"); + } + + public TestChannel GetChannel(Snowflake channelId) + { + return Discord.GetChannel(channelId).Result as TestChannel ?? throw new InvalidOperationException("Channel was not registered for mocking."); + } + + public void SetTime(DateTimeOffset date) + { + if (GetService() is not TestTimeProvider testTimeProvider) + throw new InvalidOperationException("Time provider was not an instance of TestTimeProvider"); + + testTimeProvider.SetTime(date); + } + + public static TestOrchestrator Default => Builder.Build().Result; + + public TestGuildUser CreateUser() + { + if (Discord is not TestDiscordService tds) + throw new InvalidOleVariantTypeException("Discord service was not an instance of TestDiscordService"); + + return tds.CreateUser(Snowflake.Generate()); + } + + public async Task CreateAutoMemberHold(IGuildUser user, string reason = "Test reason") + { + var dbUser = await Database.GetOrCreateUserAsync(user); + dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + Date = TimeProvider.GetUtcNow().UtcDateTime, + ModeratorID = Actor.Id, + Reason = reason + }; + await dbUser.CommitAsync(); + } + + public TestChannel CreateChannel(Snowflake channelId) + { + TestGuild guild = (TestGuild) Discord.GetGuild(); + var channel = new TestChannel(channelId); + guild.AddChannel(channel); + + return channel; + } + } +} diff --git a/InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs b/InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs new file mode 100644 index 0000000..34e6646 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs @@ -0,0 +1,132 @@ +using Discord; +using InstarBot.Test.Framework.Models; +using InstarBot.Test.Framework.Services; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.Services; +using InvalidOperationException = Amazon.CloudWatchLogs.Model.InvalidOperationException; + +namespace InstarBot.Test.Framework; + +public class TestServiceProviderBuilder +{ + private const string DefaultConfigPath = "Config/Instar.dynamic.test.debug.conf.json"; + + private readonly Dictionary _serviceRegistry = new(); + private readonly Dictionary _serviceTypeRegistry = new(); + private readonly HashSet _componentRegistry = new(); + private string _configPath = DefaultConfigPath; + private TestDiscordContextBuilder? _discordContextBuilder = null; + private TestDatabaseContextBuilder? _databaseContextBuilder = null; + private readonly Dictionary _interactionCallerIds = new(); + private Snowflake _actor = Snowflake.Generate(); + + private TestGuildUser _subject = new(Snowflake.Generate()); + + public TestServiceProviderBuilder WithConfigPath(string configPath) + { + _configPath = configPath; + return this; + } + + public TestServiceProviderBuilder WithService() + { + _serviceTypeRegistry[typeof(T)] = typeof(V); + return this; + } + + public TestServiceProviderBuilder WithService(Mock serviceMock) where T : class + { + return WithService(serviceMock.Object); + } + + public TestServiceProviderBuilder WithService(T serviceMock) where T : class + { + _serviceRegistry[typeof(T)] = serviceMock; + return this; + } + + public TestServiceProviderBuilder WithTime(DateTimeOffset? time) + { + return time is null ? this : WithService(new TestTimeProvider((DateTimeOffset) time)); + } + + public TestServiceProviderBuilder WithDiscordContext(Func builderExpr) + { + _discordContextBuilder = builderExpr(TestDiscordContext.Builder); + return this; + } + + public TestServiceProviderBuilder WithDatabase(Func builderExpr) + { + _databaseContextBuilder ??= new TestDatabaseContextBuilder(ref _discordContextBuilder); + _databaseContextBuilder = builderExpr(_databaseContextBuilder); + return this; + } + + public TestServiceProviderBuilder WithActor(Snowflake userId) + { + _actor = userId; + return this; + } + + public TestServiceProviderBuilder WithSubject(TestGuildUser user) + { + _subject = user; + return this; + } + + public async Task Build() + { + var services = new ServiceCollection(); + foreach (var (type, implementation) in _serviceRegistry) + services.AddSingleton(type, implementation); + foreach (var (iType, implType) in _serviceTypeRegistry) + services.AddSingleton(iType, implType); + foreach (var type in _componentRegistry) + services.AddTransient(type); + + IDynamicConfigService configService; + if (_serviceRegistry.TryGetValue(typeof(IDynamicConfigService), out var registeredService) && + registeredService is IDynamicConfigService resolved) + configService = resolved; + else + configService = new TestDynamicConfigService(_configPath); + + _discordContextBuilder ??= TestDiscordContext.Builder; + await _discordContextBuilder.LoadFromConfig(configService); + + // Register default services + RegisterDefaultService(services, configService); + RegisterDefaultService(services, TestTimeProvider.System); + RegisterDefaultService(services); + RegisterDefaultService(services, new TestDiscordService(_discordContextBuilder.Build())); + RegisterDefaultService(services); + RegisterDefaultService(services); + RegisterDefaultService(services); + RegisterDefaultService(services); + RegisterDefaultService(services); + + return new TestOrchestrator(services.BuildServiceProvider(), _actor, _subject); + } + + private void RegisterDefaultService(ServiceCollection collection) where T : class + { + if (!_serviceRegistry.ContainsKey(typeof(T)) && !_serviceTypeRegistry.ContainsKey(typeof(T))) + collection.AddSingleton(); + } + + private void RegisterDefaultService(ServiceCollection collection, T impl) where T : class + { + if (!_serviceRegistry.ContainsKey(typeof(T)) && !_serviceTypeRegistry.ContainsKey(typeof(T))) + collection.AddSingleton(impl); + } + + private void RegisterDefaultService(ServiceCollection collection) where T : class where V : class, T + { + if (!_serviceRegistry.ContainsKey(typeof(T)) && !_serviceTypeRegistry.ContainsKey(typeof(T))) + collection.AddSingleton(); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/TestTimeProvider.cs b/InstarBot.Tests.Orchestrator/TestTimeProvider.cs new file mode 100644 index 0000000..45cf7f9 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/TestTimeProvider.cs @@ -0,0 +1,28 @@ +namespace InstarBot.Test.Framework; + +public class TestTimeProvider : TimeProvider +{ + public new static TestTimeProvider System => new(); + + private DateTimeOffset? _time; + + public TestTimeProvider() + { + _time = null; + } + + public TestTimeProvider(DateTimeOffset time) + { + SetTime(time); + } + + public void SetTime(DateTimeOffset time) + { + _time = time; + } + + public override DateTimeOffset GetUtcNow() + { + return _time is null ? DateTimeOffset.UtcNow : ((DateTimeOffset) _time).ToUniversalTime(); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj index 73c5914..4b438cd 100644 --- a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj +++ b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj @@ -29,6 +29,7 @@ + diff --git a/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs b/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs index c2cb16b..2cd9cbc 100644 --- a/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs +++ b/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs @@ -1,12 +1,12 @@ -using System.Collections.Generic; -using System.Threading.Tasks; using Discord; using FluentAssertions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Moq; -using PaxAndromeda.Instar; using PaxAndromeda.Instar.Preconditions; +using System.Collections.Generic; +using System.Threading.Tasks; +using InstarBot.Test.Framework; using Xunit; namespace InstarBot.Tests.Preconditions; @@ -23,13 +23,14 @@ public async Task CheckRequirementsAsync_ShouldReturnFalse_WithBadConfig() .AddInMemoryCollection(new Dictionary()) .Build()); - var context = TestUtilities.SetupContext(new TestContext - { - UserRoles = [new Snowflake(793607635608928257)] - }); + var orchestrator = TestOrchestrator.Default; + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.StaffRoleID); - // Act - var result = await attr.CheckRequirementsAsync(context.Object, null!, serviceColl.BuildServiceProvider()); + var context = new Mock(); + context.Setup(n => n.User).Returns(orchestrator.Subject); + + // Act + var result = await attr.CheckRequirementsAsync(context.Object, null!, serviceColl.BuildServiceProvider()); // Assert result.IsSuccess.Should().BeFalse(); @@ -41,11 +42,13 @@ public async Task CheckRequirementsAsync_ShouldReturnFalse_WithNonGuildUser() // Arrange var attr = new RequireStaffMemberAttribute(); - var context = new Mock(); + var orchestrator = TestOrchestrator.Default; + + var context = new Mock(); context.Setup(n => n.User).Returns(Mock.Of()); // Act - var result = await attr.CheckRequirementsAsync(context.Object, null!, TestUtilities.GetServices()); + var result = await attr.CheckRequirementsAsync(context.Object, null!, orchestrator.ServiceProvider); // Assert result.IsSuccess.Should().BeFalse(); @@ -57,13 +60,14 @@ public async Task CheckRequirementsAsync_ShouldReturnSuccessful_WithValidUser() // Arrange var attr = new RequireStaffMemberAttribute(); - var context = TestUtilities.SetupContext(new TestContext - { - UserRoles = [new Snowflake(793607635608928257)] - }); + var orchestrator = TestOrchestrator.Default; + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.StaffRoleID); - // Act - var result = await attr.CheckRequirementsAsync(context.Object, null!, TestUtilities.GetServices()); + var context = new Mock(); + context.Setup(n => n.User).Returns(orchestrator.Subject); + + // Act + var result = await attr.CheckRequirementsAsync(context.Object, null!, orchestrator.ServiceProvider); // Assert result.IsSuccess.Should().BeTrue(); @@ -74,14 +78,15 @@ public async Task CheckRequirementsAsync_ShouldReturnFailure_WithNonStaffUser() { // Arrange var attr = new RequireStaffMemberAttribute(); + + var orchestrator = TestOrchestrator.Default; + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); - var context = TestUtilities.SetupContext(new TestContext - { - UserRoles = [new Snowflake()] - }); + var context = new Mock(); + context.Setup(n => n.User).Returns(orchestrator.Subject); - // Act - var result = await attr.CheckRequirementsAsync(context.Object, null!, TestUtilities.GetServices()); + // Act + var result = await attr.CheckRequirementsAsync(context.Object, null!, orchestrator.ServiceProvider); // Assert result.IsSuccess.Should().BeFalse(); diff --git a/InstarBot.Tests.Unit/SnowflakeTests.cs b/InstarBot.Tests.Unit/SnowflakeTests.cs index 08f3126..29127bf 100644 --- a/InstarBot.Tests.Unit/SnowflakeTests.cs +++ b/InstarBot.Tests.Unit/SnowflakeTests.cs @@ -186,9 +186,8 @@ public void Generate_ShouldBeUnique() var snowflake1 = Snowflake.Generate(); var snowflake2 = Snowflake.Generate(); - // Assert - snowflake1.GeneratedId.Should().Be(1); - snowflake2.GeneratedId.Should().Be(2); + // Assert + snowflake2.GeneratedId.Should().BeGreaterThan(snowflake1.GeneratedId); snowflake1.Should().NotBe(snowflake2); } diff --git a/InstarBot.sln b/InstarBot.sln index 1512192..e69bdfb 100644 --- a/InstarBot.sln +++ b/InstarBot.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.4.33213.308 +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11304.174 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InstarBot", "InstarBot\InstarBot.csproj", "{5663F6B4-9A4E-4007-9D2B-8552BB3645A6}" EndProject @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InstarBot.Tests.Common", "I EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{7F053D1A-E45A-4D1E-B667-9A678B46138B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InstarBot.Test.Framework", "InstarBot.Tests.Orchestrator\InstarBot.Test.Framework.csproj", "{00CF1D99-E691-4E8F-ABDE-9167E60CFE35}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -35,16 +37,21 @@ Global {1136B00A-B649-4D85-8DF3-D506C7726EAA}.Debug|Any CPU.Build.0 = Debug|Any CPU {1136B00A-B649-4D85-8DF3-D506C7726EAA}.Release|Any CPU.ActiveCfg = Release|Any CPU {1136B00A-B649-4D85-8DF3-D506C7726EAA}.Release|Any CPU.Build.0 = Release|Any CPU + {00CF1D99-E691-4E8F-ABDE-9167E60CFE35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00CF1D99-E691-4E8F-ABDE-9167E60CFE35}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00CF1D99-E691-4E8F-ABDE-9167E60CFE35}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00CF1D99-E691-4E8F-ABDE-9167E60CFE35}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {9C6230CA-16D9-482C-8576-82F3F7D72843} - EndGlobalSection GlobalSection(NestedProjects) = preSolution - {1136B00A-B649-4D85-8DF3-D506C7726EAA} = {7F053D1A-E45A-4D1E-B667-9A678B46138B} - {6013C4E2-F315-4F98-B9CF-8A7700F6E0EF} = {7F053D1A-E45A-4D1E-B667-9A678B46138B} {765E61BC-2331-4AC2-BE0D-372F87D4B85F} = {7F053D1A-E45A-4D1E-B667-9A678B46138B} + {6013C4E2-F315-4F98-B9CF-8A7700F6E0EF} = {7F053D1A-E45A-4D1E-B667-9A678B46138B} + {1136B00A-B649-4D85-8DF3-D506C7726EAA} = {7F053D1A-E45A-4D1E-B667-9A678B46138B} + {00CF1D99-E691-4E8F-ABDE-9167E60CFE35} = {7F053D1A-E45A-4D1E-B667-9A678B46138B} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {9C6230CA-16D9-482C-8576-82F3F7D72843} EndGlobalSection EndGlobal diff --git a/InstarBot/AppContext.cs b/InstarBot/AppContext.cs index 0dc5254..694cc9a 100644 --- a/InstarBot/AppContext.cs +++ b/InstarBot/AppContext.cs @@ -2,4 +2,5 @@ [assembly: InternalsVisibleTo("InstarBot.Tests.Unit")] [assembly: InternalsVisibleTo("InstarBot.Tests.Common")] -[assembly: InternalsVisibleTo("InstarBot.Tests.Integration")] \ No newline at end of file +[assembly: InternalsVisibleTo("InstarBot.Tests.Integration")] +[assembly: InternalsVisibleTo("InstarBot.Test.Framework")] \ No newline at end of file diff --git a/InstarBot/Commands/AutoMemberHoldCommand.cs b/InstarBot/Commands/AutoMemberHoldCommand.cs index 7bc606d..5f1d44a 100644 --- a/InstarBot/Commands/AutoMemberHoldCommand.cs +++ b/InstarBot/Commands/AutoMemberHoldCommand.cs @@ -11,7 +11,7 @@ namespace PaxAndromeda.Instar.Commands; [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class AutoMemberHoldCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfigService, IMetricService metricService, TimeProvider timeProvider) : BaseCommand +public class AutoMemberHoldCommand(IDatabaseService ddbService, IDynamicConfigService dynamicConfigService, IMetricService metricService, TimeProvider timeProvider) : BaseCommand { [UsedImplicitly] [SlashCommand("amh", "Withhold automatic membership grants to a user.")] @@ -60,7 +60,7 @@ string reason Reason = reason, Date = date }; - await dbUser.UpdateAsync(); + await dbUser.CommitAsync(); // TODO: configurable duration? await RespondAsync(string.Format(Strings.Command_AutoMemberHold_Success, user.Id), ephemeral: true); @@ -106,7 +106,7 @@ IUser user } dbUser.Data.AutoMemberHoldRecord = null; - await dbUser.UpdateAsync(); + await dbUser.CommitAsync(); await RespondAsync(string.Format(Strings.Command_AutoMemberUnhold_Success, user.Id), ephemeral: true); } diff --git a/InstarBot/Commands/CheckEligibilityCommand.cs b/InstarBot/Commands/CheckEligibilityCommand.cs index d429cb3..0646b62 100644 --- a/InstarBot/Commands/CheckEligibilityCommand.cs +++ b/InstarBot/Commands/CheckEligibilityCommand.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Discord; using Discord.Interactions; using JetBrains.Annotations; @@ -6,7 +7,6 @@ using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Serilog; -using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Commands; @@ -14,7 +14,7 @@ namespace PaxAndromeda.Instar.Commands; public class CheckEligibilityCommand( IDynamicConfigService dynamicConfig, IAutoMemberSystem autoMemberSystem, - IInstarDDBService ddbService, + IDatabaseService ddbService, IMetricService metricService) : BaseCommand { diff --git a/InstarBot/Commands/PageCommand.cs b/InstarBot/Commands/PageCommand.cs index ac885a0..04b706c 100644 --- a/InstarBot/Commands/PageCommand.cs +++ b/InstarBot/Commands/PageCommand.cs @@ -1,4 +1,5 @@ -using Ardalis.GuardClauses; +using System.Diagnostics.CodeAnalysis; +using Ardalis.GuardClauses; using Discord; using Discord.Interactions; using JetBrains.Annotations; @@ -8,7 +9,6 @@ using PaxAndromeda.Instar.Preconditions; using PaxAndromeda.Instar.Services; using Serilog; -using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Commands; diff --git a/InstarBot/Commands/ResetBirthdayCommand.cs b/InstarBot/Commands/ResetBirthdayCommand.cs index 44914d6..0b460c1 100644 --- a/InstarBot/Commands/ResetBirthdayCommand.cs +++ b/InstarBot/Commands/ResetBirthdayCommand.cs @@ -7,7 +7,7 @@ namespace PaxAndromeda.Instar.Commands; -public class ResetBirthdayCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfig) : BaseCommand +public class ResetBirthdayCommand(IDatabaseService ddbService, IDynamicConfigService dynamicConfig) : BaseCommand { /* * Concern: This command runs very slowly, and might end up hitting the 3-second limit from Discord. @@ -33,7 +33,7 @@ IUser user dbUser.Data.Birthday = null; dbUser.Data.Birthdate = null; - await dbUser.UpdateAsync(); + await dbUser.CommitAsync(); if (user is IGuildUser guildUser) { diff --git a/InstarBot/Commands/SetBirthdayCommand.cs b/InstarBot/Commands/SetBirthdayCommand.cs index af2d894..309effe 100644 --- a/InstarBot/Commands/SetBirthdayCommand.cs +++ b/InstarBot/Commands/SetBirthdayCommand.cs @@ -1,4 +1,5 @@ -using Discord; +using System.Diagnostics.CodeAnalysis; +using Discord; using Discord.Interactions; using Discord.WebSocket; using JetBrains.Annotations; @@ -8,13 +9,12 @@ using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Serilog; -using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Commands; // Required to be unsealed for mocking [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class SetBirthdayCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfig, IMetricService metricService, IBirthdaySystem birthdaySystem, TimeProvider timeProvider) : BaseCommand +public class SetBirthdayCommand(IDatabaseService ddbService, IDynamicConfigService dynamicConfig, IMetricService metricService, IBirthdaySystem birthdaySystem, TimeProvider timeProvider) : BaseCommand { /// /// The default year to use when none is provided. We select a year that is sufficiently @@ -123,7 +123,7 @@ await RespondAsync( }; } - await dbUser.UpdateAsync(); + await dbUser.CommitAsync(); Log.Information("User {UserID} birthday set to {DateTime} (UTC time calculated as {UtcTime})", Context.User!.Id, diff --git a/InstarBot/DynamoModels/InstarDatabaseEntry.cs b/InstarBot/DynamoModels/InstarDatabaseEntry.cs index 938275f..a350ba3 100644 --- a/InstarBot/DynamoModels/InstarDatabaseEntry.cs +++ b/InstarBot/DynamoModels/InstarDatabaseEntry.cs @@ -21,6 +21,13 @@ public sealed class InstarDatabaseEntry(IDynamoDBContext context, T data) /// Persists changes to the underlying storage system asynchronously. /// /// A that can be used to poll or wait for results, or both - public Task UpdateAsync() + public Task CommitAsync() => context.SaveAsync(Data); + + /// + /// Asynchronously deletes the associated data from the database. + /// + /// A that can be used to poll or wait for results, or both + public Task DeleteAsync() + => context.DeleteAsync(Data); } \ No newline at end of file diff --git a/InstarBot/DynamoModels/InstarUserData.cs b/InstarBot/DynamoModels/InstarUserData.cs index f160da6..8a54478 100644 --- a/InstarBot/DynamoModels/InstarUserData.cs +++ b/InstarBot/DynamoModels/InstarUserData.cs @@ -243,7 +243,7 @@ public class InstarEnumPropertyConverter : IPropertyConverter where T : Enum { public DynamoDBEntry ToEntry(object value) { - var pos = (InstarUserPosition) value; + var pos = (T) value; var name = pos.GetAttributeOfType(); return name?.Value ?? "UNKNOWN"; @@ -253,7 +253,7 @@ public object FromEntry(DynamoDBEntry entry) { var sEntry = entry.AsString(); if (sEntry is null || string.IsNullOrWhiteSpace(entry.AsString())) - return InstarUserPosition.Unknown; + return null; var name = Utilities.ToEnum(sEntry); diff --git a/InstarBot/DynamoModels/Notification.cs b/InstarBot/DynamoModels/Notification.cs new file mode 100644 index 0000000..ac8b7dc --- /dev/null +++ b/InstarBot/DynamoModels/Notification.cs @@ -0,0 +1,70 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; +using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; + +namespace PaxAndromeda.Instar.DynamoModels; + +[DynamoDBTable("InstarNotifications")] +[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] +public class Notification +{ + [DynamoDBHashKey("guild_id", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake? GuildID { get; set; } + + [DynamoDBRangeKey("date")] + public DateTime Date { get; set; } + + [DynamoDBProperty("actor", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake? Actor { get; set; } + + [DynamoDBProperty("subject")] + public string Subject { get; set; } + + [DynamoDBProperty("channel", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake? Channel { get; set; } + + [DynamoDBProperty("target")] + public List Targets { get; set; } + + [DynamoDBProperty("priority", Converter = typeof(InstarEnumPropertyConverter))] + public NotificationPriority? Priority { get; set; } = NotificationPriority.Normal; + + [DynamoDBProperty("data")] + public NotificationData Data { get; set; } +} + +public record NotificationTarget +{ + [DynamoDBProperty("type", Converter = typeof(InstarEnumPropertyConverter))] + public NotificationTargetType Type { get; set; } + + [DynamoDBProperty("id", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake? Id { get; set; } +} + +public record NotificationData +{ + [DynamoDBProperty("message")] + public string? Message { get; set; } +} + +public enum NotificationTargetType +{ + [EnumMember(Value = "ROLE")] + Role, + [EnumMember(Value = "USER")] + User +} + +public enum NotificationPriority +{ + [EnumMember(Value = "LOW")] + Low, + + [EnumMember(Value = "NORMAL")] + Normal, + + [EnumMember(Value = "HIGH")] + High +} \ No newline at end of file diff --git a/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs b/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs index 0f00f4f..5a135eb 100644 --- a/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs +++ b/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs @@ -1,5 +1,5 @@ -using Discord; -using System.Text; +using System.Text; +using Discord; using PaxAndromeda.Instar.ConfigModels; namespace PaxAndromeda.Instar.Embeds; diff --git a/InstarBot/Embeds/InstarEligibilityEmbed.cs b/InstarBot/Embeds/InstarEligibilityEmbed.cs index 1493e52..8294819 100644 --- a/InstarBot/Embeds/InstarEligibilityEmbed.cs +++ b/InstarBot/Embeds/InstarEligibilityEmbed.cs @@ -1,7 +1,7 @@ -using Discord; -using PaxAndromeda.Instar.DynamoModels; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Text; +using Discord; +using PaxAndromeda.Instar.DynamoModels; namespace PaxAndromeda.Instar.Embeds; diff --git a/InstarBot/IBuilder.cs b/InstarBot/IBuilder.cs new file mode 100644 index 0000000..ef77248 --- /dev/null +++ b/InstarBot/IBuilder.cs @@ -0,0 +1,6 @@ +namespace PaxAndromeda.Instar; + +public interface IBuilder +{ + T Build(); +} \ No newline at end of file diff --git a/InstarBot/InstarBot.csproj b/InstarBot/InstarBot.csproj index d7365e4..16d4c96 100644 --- a/InstarBot/InstarBot.csproj +++ b/InstarBot/InstarBot.csproj @@ -32,6 +32,7 @@ + diff --git a/InstarBot/Metrics/Metric.cs b/InstarBot/Metrics/Metric.cs index 59104d1..2515155 100644 --- a/InstarBot/Metrics/Metric.cs +++ b/InstarBot/Metrics/Metric.cs @@ -5,6 +5,13 @@ namespace PaxAndromeda.Instar.Metrics; [SuppressMessage("ReSharper", "InconsistentNaming")] public enum Metric { + + [MetricName("Schedule Deviation")] + ScheduledService_ScheduleDeviation, + + [MetricName("Runtime")] + ScheduledService_ServiceRuntime, + [MetricDimension("Service", "Paging System")] [MetricName("Pages Sent")] Paging_SentPages, diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index 81240c8..5dfd878 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -1,7 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Amazon; using Amazon.CloudWatchLogs; -using CommandLine; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using PaxAndromeda.Instar.Commands; @@ -74,8 +73,8 @@ private static async Task RunAsync(IConfiguration config) // Start up other systems List tasks = [ - _services.GetRequiredService().Initialize(), - _services.GetRequiredService().Initialize() + _services.GetRequiredService().Start(), + _services.GetRequiredService().Start() ]; Task.WaitAll(tasks); @@ -90,13 +89,14 @@ private static void InitializeLogger(IConfiguration config) #else const LogEventLevel minLevel = LogEventLevel.Information; #endif - - var logCfg = new LoggerConfiguration() - .Enrich.FromLogContext() - .MinimumLevel.Is(minLevel) + + var logCfg = new LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.Is(minLevel) .WriteTo.Console(); - var awsSection = config.GetSection("AWS"); + + var awsSection = config.GetSection("AWS"); var cwSection = awsSection.GetSection("CloudWatch"); if (cwSection.GetValue("Enabled")) { @@ -129,7 +129,7 @@ private static ServiceProvider ConfigureServices(IConfiguration config) // Services services.AddSingleton(); - services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddSingleton(); services.AddSingleton(); diff --git a/InstarBot/Services/AWSDynamicConfigService.cs b/InstarBot/Services/AWSDynamicConfigService.cs index e71edaf..1b8d7f4 100644 --- a/InstarBot/Services/AWSDynamicConfigService.cs +++ b/InstarBot/Services/AWSDynamicConfigService.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text; -using Amazon.AppConfigData; using Amazon; +using Amazon.AppConfigData; using Amazon.AppConfigData.Model; using Amazon.SimpleSystemsManagement; using Amazon.SimpleSystemsManagement.Model; diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index d2c5d72..def17c3 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; using System.Runtime.Caching; -using System.Timers; using Discord; using Discord.WebSocket; using PaxAndromeda.Instar.Caching; @@ -10,11 +9,10 @@ using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Modals; using Serilog; -using Timer = System.Timers.Timer; namespace PaxAndromeda.Instar.Services; -public sealed class AutoMemberSystem : IAutoMemberSystem +public sealed class AutoMemberSystem : ScheduledService, IAutoMemberSystem { private readonly MemoryCache _ddbCache = new("AutoMemberSystem_DDBCache"); private readonly MemoryCache _messageCache = new("AutoMemberSystem_MessageCache"); @@ -26,10 +24,9 @@ public sealed class AutoMemberSystem : IAutoMemberSystem private readonly IDynamicConfigService _dynamicConfig; private readonly IDiscordService _discord; private readonly IGaiusAPIService _gaiusApiService; - private readonly IInstarDDBService _ddbService; + private readonly IDatabaseService _ddbService; private readonly IMetricService _metricService; private readonly TimeProvider _timeProvider; - private Timer _timer = null!; /// /// Recent messages per the last AMS run @@ -37,7 +34,8 @@ public sealed class AutoMemberSystem : IAutoMemberSystem private Dictionary? _recentMessages; public AutoMemberSystem(IDynamicConfigService dynamicConfig, IDiscordService discord, IGaiusAPIService gaiusApiService, - IInstarDDBService ddbService, IMetricService metricService, TimeProvider timeProvider) + IDatabaseService ddbService, IMetricService metricService, TimeProvider timeProvider) + : base("0 * * * *", timeProvider, metricService, "Auto Member System") { _dynamicConfig = dynamicConfig; _discord = discord; @@ -53,7 +51,7 @@ public AutoMemberSystem(IDynamicConfigService dynamicConfig, IDiscordService dis discord.MessageDeleted += HandleMessageDeleted; } - public async Task Initialize() + internal override async Task Initialize() { var cfg = await _dynamicConfig.GetConfig(); @@ -64,8 +62,6 @@ public async Task Initialize() if (cfg.AutoMemberConfig.EnableGaiusCheck) await PreloadGaiusPunishments(); - - StartTimer(); } /// @@ -169,7 +165,7 @@ private async Task HandleUserJoined(IGuildUser user) case InstarUserPosition.Unknown: await user.AddRoleAsync(cfg.NewMemberRoleID); dbUser.Data.Position = InstarUserPosition.NewMember; - await dbUser.UpdateAsync(); + await dbUser.CommitAsync(); break; default: @@ -228,45 +224,11 @@ private async Task HandleUserUpdated(UserUpdatedEventArgs arg) if (changed) { Log.Information("Updated metadata for user {Username} (user ID {UserID})", arg.After.Username, arg.ID); - await user.UpdateAsync(); + await user.CommitAsync(); } } - - private void StartTimer() - { - // Since we can start the bot in the middle of an hour, - // first we must determine the time until the next top - // of hour. - var currentTime = _timeProvider.GetUtcNow().UtcDateTime; - var nextHour = new DateTime(currentTime.Year, currentTime.Month, currentTime.Day, - currentTime.Hour, 0, 0).AddHours(1); - var millisecondsRemaining = (nextHour - currentTime).TotalMilliseconds; - - // Start the timer. In elapsed step, we reset the - // duration to exactly 1 hour. - _timer = new Timer(millisecondsRemaining); - _timer.Elapsed += TimerElapsed; - _timer.Start(); - - Log.Information("Auto member system timer started, first run in {SecondsRemaining} seconds.", millisecondsRemaining / 1000); - } - - private async void TimerElapsed(object? sender, ElapsedEventArgs e) - { - try - { - // Ensure the timer's interval is exactly 1 hour - _timer.Interval = 60 * 60 * 1000; - - await RunAsync(); - } - catch - { - // ignore - } - } - public async Task RunAsync() + public override async Task RunAsync() { try { @@ -383,7 +345,7 @@ private async Task GrantMembership(InstarDynamicConfiguration cfg, IGuildUser us await user.RemoveRoleAsync(cfg.NewMemberRoleID); dbUser.Data.Position = InstarUserPosition.Member; - await dbUser.UpdateAsync(); + await dbUser.CommitAsync(); // Remove the cache entry if (_ddbCache.Contains(user.Id.ToString())) diff --git a/InstarBot/Services/BirthdaySystem.cs b/InstarBot/Services/BirthdaySystem.cs index 87b37ab..6f4aba4 100644 --- a/InstarBot/Services/BirthdaySystem.cs +++ b/InstarBot/Services/BirthdaySystem.cs @@ -7,72 +7,28 @@ using Metric = PaxAndromeda.Instar.Metrics.Metric; namespace PaxAndromeda.Instar.Services; -using System.Timers; public sealed class BirthdaySystem ( IDynamicConfigService dynamicConfig, IDiscordService discord, - IInstarDDBService ddbService, + IDatabaseService ddbService, IMetricService metricService, TimeProvider timeProvider) - : IBirthdaySystem + : ScheduledService("*/5 * * * *", timeProvider, metricService, "Birthday System"), IBirthdaySystem { /// /// The maximum age to be considered 'valid' for age role assignment. /// private const int MaximumAge = 150; - private Timer _timer = null!; - [ExcludeFromCodeCoverage] - public Task Initialize() + internal override Task Initialize() { - StartTimer(); + // nothing to do return Task.CompletedTask; } - [ExcludeFromCodeCoverage] - private void StartTimer() - { - // We need to run the birthday check every 30 minutes - // to accomodate any time zone differences. - - var currentTime = timeProvider.GetUtcNow().UtcDateTime; - - bool firstHalfHour = currentTime.Minute < 30; - DateTime currentHour = new (currentTime.Year, currentTime.Month, currentTime.Day, - currentTime.Hour, 0, 0, DateTimeKind.Utc); - - DateTime firstRun = firstHalfHour ? currentHour.AddMinutes(30) : currentHour.AddHours(1); - - var millisecondsRemaining = (firstRun - currentTime).TotalMilliseconds; - - _timer = new Timer(millisecondsRemaining); - _timer.Elapsed += TimerElapsed; - _timer.Start(); - - - - Log.Information("Birthday system timer started, first run in {SecondsRemaining} seconds.", millisecondsRemaining / 1000); - } - - [ExcludeFromCodeCoverage] - private async void TimerElapsed(object? sender, ElapsedEventArgs e) - { - try - { - // Ensure the timer's interval is exactly 30 minutes. - _timer.Interval = 30 * 60 * 1000; - - await RunAsync(); - } - catch - { - // ignore - } - } - - public async Task RunAsync() + public override async Task RunAsync() { var cfg = await dynamicConfig.GetConfig(); var currentTime = timeProvider.GetUtcNow().UtcDateTime; diff --git a/InstarBot/Services/CloudwatchMetricService.cs b/InstarBot/Services/CloudwatchMetricService.cs index 94d5a64..62c8e60 100644 --- a/InstarBot/Services/CloudwatchMetricService.cs +++ b/InstarBot/Services/CloudwatchMetricService.cs @@ -1,9 +1,11 @@ +using System.Collections.Specialized; using System.Net; using Amazon; using Amazon.CloudWatch; using Amazon.CloudWatch.Model; using Amazon.Runtime; using Microsoft.Extensions.Configuration; +using PaxAndromeda.Instar; using PaxAndromeda.Instar.Metrics; using Serilog; using Metric = PaxAndromeda.Instar.Metrics.Metric; @@ -26,8 +28,29 @@ public CloudwatchMetricService(IConfiguration config) _client = new AmazonCloudWatchClient(new AWSIAMCredential(config), RegionEndpoint.GetBySystemName(region)); } - public async Task Emit(Metric metric, double value) - { + public Task Emit(Metric metric, double value) + { + try + { + var dimensions = new Dictionary(); + + var attrs = metric.GetAttributesOfType(); + if (attrs == null) + return Emit(metric, value, dimensions); + + foreach (var dim in attrs) + dimensions.Add(dim.Name, dim.Value); + + return Emit(metric, value, dimensions); + } catch (Exception ex) + { + Log.Error(ex, "Failed to emit metric {Metric} with value {Value}", metric, value); + return Task.FromResult(false); + } + } + + public async Task Emit(Metric metric, double value, Dictionary dimensions) + { for (var attempt = 1; attempt <= MaxAttempts; attempt++) { try @@ -45,6 +68,10 @@ public async Task Emit(Metric metric, double value) if (attrs != null) foreach (var dim in attrs) { + // Always prefer the passed-in dimensions over attribute-defined ones when there's a conflict + if (!dimensions.ContainsKey(dim.Name)) + dimensions.Add(dim.Name, dim.Value); + datum.Dimensions.Add(new Dimension { Name = dim.Name, @@ -52,6 +79,9 @@ public async Task Emit(Metric metric, double value) }); } + foreach (var (dName, dValue) in dimensions) + datum.Dimensions.Add(new Dimension { Name = dName, Value = dValue }); + var response = await _client.PutMetricDataAsync(new PutMetricDataRequest { Namespace = _metricNamespace, @@ -59,7 +89,8 @@ public async Task Emit(Metric metric, double value) }); return response.HttpStatusCode == HttpStatusCode.OK; - } catch (Exception ex) when (IsTransient(ex) && attempt < MaxAttempts) + } + catch (Exception ex) when (IsTransient(ex) && attempt < MaxAttempts) { var expo = Math.Pow(2, attempt - 1); var jitter = TimeSpan.FromMilliseconds(new Random().NextDouble() * 100); diff --git a/InstarBot/Services/DiscordService.cs b/InstarBot/Services/DiscordService.cs index 0c5c66e..0ec0a34 100644 --- a/InstarBot/Services/DiscordService.cs +++ b/InstarBot/Services/DiscordService.cs @@ -1,4 +1,5 @@ -using Discord; +using System.Diagnostics.CodeAnalysis; +using Discord; using Discord.Interactions; using Discord.WebSocket; using Microsoft.Extensions.Configuration; @@ -8,7 +9,6 @@ using PaxAndromeda.Instar.Wrappers; using Serilog; using Serilog.Events; -using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Services; diff --git a/InstarBot/Services/FileSystemMetricService.cs b/InstarBot/Services/FileSystemMetricService.cs index f1d307b..6359d97 100644 --- a/InstarBot/Services/FileSystemMetricService.cs +++ b/InstarBot/Services/FileSystemMetricService.cs @@ -1,5 +1,6 @@ using System.Reflection; using PaxAndromeda.Instar.Metrics; +using Serilog; namespace PaxAndromeda.Instar.Services; @@ -22,6 +23,12 @@ public void Initialize() public Task Emit(Metric metric, double value) { + return Emit(metric, value, new Dictionary()); + } + + public Task Emit(Metric metric, double value, Dictionary dimensions) + { + Log.Debug("[METRIC] {MetricName} Value: {Value}", Enum.GetName(metric), value); return Task.FromResult(true); } diff --git a/InstarBot/Services/GaiusAPIService.cs b/InstarBot/Services/GaiusAPIService.cs index 978b18f..da88f51 100644 --- a/InstarBot/Services/GaiusAPIService.cs +++ b/InstarBot/Services/GaiusAPIService.cs @@ -1,7 +1,7 @@ using System.Diagnostics; +using System.Text; using Newtonsoft.Json; using PaxAndromeda.Instar.Gaius; -using System.Text; using PaxAndromeda.Instar.Metrics; namespace PaxAndromeda.Instar.Services; diff --git a/InstarBot/Services/IAutoMemberSystem.cs b/InstarBot/Services/IAutoMemberSystem.cs index ee221dd..16a2d7d 100644 --- a/InstarBot/Services/IAutoMemberSystem.cs +++ b/InstarBot/Services/IAutoMemberSystem.cs @@ -3,10 +3,8 @@ namespace PaxAndromeda.Instar.Services; -public interface IAutoMemberSystem +public interface IAutoMemberSystem : IScheduledService { - Task RunAsync(); - /// /// Determines the eligibility of a user for membership based on specific criteria. /// @@ -25,6 +23,4 @@ public interface IAutoMemberSystem /// /// MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IGuildUser user); - - Task Initialize(); } \ No newline at end of file diff --git a/InstarBot/Services/IBirthdaySystem.cs b/InstarBot/Services/IBirthdaySystem.cs index 631f9f6..e148915 100644 --- a/InstarBot/Services/IBirthdaySystem.cs +++ b/InstarBot/Services/IBirthdaySystem.cs @@ -2,11 +2,8 @@ namespace PaxAndromeda.Instar.Services; -public interface IBirthdaySystem +public interface IBirthdaySystem : IScheduledService { - Task Initialize(); - Task RunAsync(); - /// /// Grants the birthday role to a user outside the normal birthday check process. For example, a /// user sets their birthday to today via command. diff --git a/InstarBot/Services/IInstarDDBService.cs b/InstarBot/Services/IDatabaseService.cs similarity index 65% rename from InstarBot/Services/IInstarDDBService.cs rename to InstarBot/Services/IDatabaseService.cs index 1bcae19..8653cf8 100644 --- a/InstarBot/Services/IInstarDDBService.cs +++ b/InstarBot/Services/IDatabaseService.cs @@ -5,7 +5,7 @@ namespace PaxAndromeda.Instar.Services; [SuppressMessage("ReSharper", "UnusedMemberInSuper.Global")] -public interface IInstarDDBService +public interface IDatabaseService { /// /// Retrieves user data from DynamoDB for a provided . @@ -14,19 +14,19 @@ public interface IInstarDDBService /// User data associated with the provided , if any exists /// If the `position` entry does not represent a valid Task?> GetUserAsync(Snowflake snowflake); - - - /// - /// Retrieves or creates user data from a provided . - /// - /// An instance of . If a new user must be created, - /// information will be pulled from the parameter. - /// An instance of . - /// - /// When a new user is created with this method, it is *not* created in DynamoDB until - /// is called. - /// - Task> GetOrCreateUserAsync(IGuildUser user); + + + /// + /// Retrieves or creates user data from a provided . + /// + /// An instance of . If a new user must be created, + /// information will be pulled from the parameter. + /// An instance of . + /// + /// When a new user is created with this method, it is *not* created in DynamoDB until + /// is called. + /// + Task> GetOrCreateUserAsync(IGuildUser user); /// /// Retrieves a list of user data from a list of . @@ -55,4 +55,18 @@ public interface IInstarDDBService /// cref="InstarDatabaseEntry{InstarUserData}"/> objects for users whose birthdays fall within the specified range. /// Returns an empty list if no users are found. Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness); + + // TODO: documentation + Task>> GetPendingNotifications(); + + /// + /// Creates a new database representation from a provided . + /// + /// An instance of . + /// An instance of . + /// + /// When a new user is created with this method, it is *not* created in DynamoDB until + /// is called. + /// + Task> CreateNotificationAsync(Notification notification); } \ No newline at end of file diff --git a/InstarBot/Services/IMetricService.cs b/InstarBot/Services/IMetricService.cs index 1eebd1c..d59eced 100644 --- a/InstarBot/Services/IMetricService.cs +++ b/InstarBot/Services/IMetricService.cs @@ -4,5 +4,6 @@ namespace PaxAndromeda.Instar.Services; public interface IMetricService { - Task Emit(Metric metric, double value); + Task Emit(Metric metric, double value); + Task Emit(Metric metric, double value, Dictionary dimensions); } \ No newline at end of file diff --git a/InstarBot/Services/IRunnableService.cs b/InstarBot/Services/IRunnableService.cs new file mode 100644 index 0000000..af7674b --- /dev/null +++ b/InstarBot/Services/IRunnableService.cs @@ -0,0 +1,9 @@ +namespace PaxAndromeda.Instar.Services; + +public interface IRunnableService +{ + /// + /// Runs the service. + /// + Task RunAsync(); +} \ No newline at end of file diff --git a/InstarBot/Services/IScheduledService.cs b/InstarBot/Services/IScheduledService.cs new file mode 100644 index 0000000..3fe7f46 --- /dev/null +++ b/InstarBot/Services/IScheduledService.cs @@ -0,0 +1,5 @@ +namespace PaxAndromeda.Instar.Services; + +public interface IScheduledService : IStartableService, IRunnableService +{ +} \ No newline at end of file diff --git a/InstarBot/Services/IStartableService.cs b/InstarBot/Services/IStartableService.cs new file mode 100644 index 0000000..4213947 --- /dev/null +++ b/InstarBot/Services/IStartableService.cs @@ -0,0 +1,9 @@ +namespace PaxAndromeda.Instar.Services; + +public interface IStartableService +{ + /// + /// Starts the scheduled service. + /// + Task Start(); +} \ No newline at end of file diff --git a/InstarBot/Services/InstarDDBService.cs b/InstarBot/Services/InstarDynamoDBService.cs similarity index 70% rename from InstarBot/Services/InstarDDBService.cs rename to InstarBot/Services/InstarDynamoDBService.cs index da3f2b9..a847162 100644 --- a/InstarBot/Services/InstarDDBService.cs +++ b/InstarBot/Services/InstarDynamoDBService.cs @@ -1,4 +1,5 @@ -using Amazon; +using System.Diagnostics.CodeAnalysis; +using Amazon; using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.DataModel; using Amazon.DynamoDBv2.DocumentModel; @@ -6,18 +7,17 @@ using Microsoft.Extensions.Configuration; using PaxAndromeda.Instar.DynamoModels; using Serilog; -using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Services; [ExcludeFromCodeCoverage] -public sealed class InstarDDBService : IInstarDDBService +public sealed class InstarDynamoDBService : IDatabaseService { private readonly TimeProvider _timeProvider; private readonly DynamoDBContext _ddbContext; private readonly string _guildId; - public InstarDDBService(IConfiguration config, TimeProvider timeProvider) + public InstarDynamoDBService(IConfiguration config, TimeProvider timeProvider) { _timeProvider = timeProvider; var region = config.GetSection("AWS").GetValue("Region"); @@ -45,16 +45,21 @@ public InstarDDBService(IConfiguration config, TimeProvider timeProvider) Log.Error(ex, "Failed to get user data for {Snowflake}", snowflake); return null; } - } + } - public async Task> GetOrCreateUserAsync(IGuildUser user) - { - var data = await _ddbContext.LoadAsync(_guildId, user.Id.ToString()) ?? InstarUserData.CreateFrom(user); + public async Task> GetOrCreateUserAsync(IGuildUser user) + { + var data = await _ddbContext.LoadAsync(_guildId, user.Id.ToString()) ?? InstarUserData.CreateFrom(user); - return new InstarDatabaseEntry(_ddbContext, data); - } + return new InstarDatabaseEntry(_ddbContext, data); + } - public async Task>> GetBatchUsersAsync(IEnumerable snowflakes) + public async Task> CreateNotificationAsync(Notification notification) + { + return new InstarDatabaseEntry(_ddbContext, notification); + } + + public async Task>> GetBatchUsersAsync(IEnumerable snowflakes) { var batches = _ddbContext.CreateBatchGet(); foreach (var snowflake in snowflakes) @@ -65,6 +70,34 @@ public async Task>> GetBatchUsersAsync( return batches.Results.Select(x => new InstarDatabaseEntry(_ddbContext, x)).ToList(); } + public async Task>> GetPendingNotifications() + { + var currentTime = _timeProvider.GetUtcNow(); + + var config = new QueryOperationConfig + { + KeyExpression = new Expression + { + ExpressionStatement = "guild_id = :g AND #DATE <= :now", + ExpressionAttributeNames = new Dictionary + { + ["#DATE"] = "date" + }, + ExpressionAttributeValues = new Dictionary + { + [":g"] = _guildId, + [":now"] = currentTime.ToString("O") + } + } + }; + + var search = _ddbContext.FromQueryAsync(config); + + var results = await search.GetRemainingAsync().ConfigureAwait(false); + + return results.Select(u => new InstarDatabaseEntry(_ddbContext, u)).ToList(); + } + public async Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness) { var birthday = new Birthday(birthdate, _timeProvider); diff --git a/InstarBot/Services/NotificationService.cs b/InstarBot/Services/NotificationService.cs new file mode 100644 index 0000000..76a9ddb --- /dev/null +++ b/InstarBot/Services/NotificationService.cs @@ -0,0 +1,50 @@ +using PaxAndromeda.Instar.DynamoModels; +using Serilog; + +namespace PaxAndromeda.Instar.Services; + +public class NotificationService : ScheduledService +{ + private readonly IDatabaseService _dbService; + private readonly IDiscordService _discordService; + private readonly IDynamicConfigService _dynamicConfig; + + public NotificationService( + TimeProvider timeProvider, + IMetricService metricService, + IDatabaseService dbService, + IDiscordService discordService, + IDynamicConfigService dynamicConfig) + : base("* * * * *", timeProvider, metricService, "Notifications Service") + { + _dbService = dbService; + _discordService = discordService; + _dynamicConfig = dynamicConfig; + } + + internal override Task Initialize() + { + return Task.CompletedTask; + } + + public override async Task RunAsync() + { + var notificationQueue = new Queue>( + (await _dbService.GetPendingNotifications()).OrderByDescending(entry => entry.Data.Date) + ); + + while (notificationQueue.TryDequeue(out var notification)) + { + if (!await ProcessNotification(notification)) + continue; + + await notification.DeleteAsync(); + } + } + + private async Task ProcessNotification(InstarDatabaseEntry notification) + { + Log.Information("Processed notification from {Actor} sent on {Date}: {Message}", notification.Data.Actor.ID, notification.Data.Date, notification.Data.Data.Message); + return true; + } +} \ No newline at end of file diff --git a/InstarBot/Services/ScheduledService.cs b/InstarBot/Services/ScheduledService.cs new file mode 100644 index 0000000..9686e35 --- /dev/null +++ b/InstarBot/Services/ScheduledService.cs @@ -0,0 +1,160 @@ +using NCrontab; +using PaxAndromeda.Instar.Metrics; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using System.Diagnostics; +using System.Timers; +using Timer = System.Timers.Timer; + +namespace PaxAndromeda.Instar.Services; + +/// +/// An abstract base class for services that need to run on a scheduled basis using cron expressions. +/// +public abstract class ScheduledService : IStartableService, IRunnableService +{ + private const string ServiceDimension = "Service"; + + private readonly string _serviceName; + private readonly TimeProvider _timeProvider; + private readonly IMetricService _metricService; + private readonly CrontabSchedule _schedule; + private readonly Enricher _enricher; + private Timer? _nextRunTimer; + private DateTime _expectedNextTime; + + /// + /// Initializes a new instance of the ScheduledService class with the specified schedule, time provider, metric + /// service, and optional service name. + /// + /// A cron expression that defines the schedule on which the service should run. Must be a valid cron format string. + /// A time provider used to determine the current time for scheduling operations. + /// A metric service used to record and report service metrics. + /// An optional name for the service which is used for the "Service" dimension in emitted metrics. If not provided, the name of + /// the derived class is used. If the name cannot be determined, defaults to "Unknown Service". + /// Thrown if the provided cron expression is not valid. + protected ScheduledService(string cronExpression, TimeProvider timeProvider, IMetricService metricService, string? serviceName = null) + { + // Hacky way to get the service name if one isn't provided + try + { + _serviceName = serviceName + ?? new StackTrace().GetFrame(1)?.GetMethod()?.ReflectedType?.Name // The name of the derived class + ?? "Unknown Service"; // Fallback if all else fails + } catch + { + // Swallow any exceptions and use the fallback + _serviceName = "Unknown Service"; + } + + _timeProvider = timeProvider; + _metricService = metricService; + _enricher = new Enricher(_serviceName); + + try + { + _schedule = CrontabSchedule.Parse(cronExpression); + } catch (Exception ex) + { + throw new ArgumentException("Provided cron expression was not valid.", nameof(cronExpression), ex); + } + } + + public async Task Start() + { + _nextRunTimer = new Timer + { + AutoReset = false, + Enabled = false + }; + + _nextRunTimer.Elapsed += TimerElapsed; + + await Initialize(); + + ScheduleNext(); + } + + private void ScheduleNext() + { + if (_nextRunTimer is null) + throw new InvalidOperationException("Service has not been started. Call Start() before scheduling the next run."); + + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + _expectedNextTime = _schedule.GetNextOccurrence(utcNow); + + Log.ForContext(_enricher).Debug("[{Service}] Next scheduled start time: {NextTime}", _serviceName, _expectedNextTime); + + var timeToNextRun = _expectedNextTime - utcNow; + + _nextRunTimer.Interval = timeToNextRun.TotalMilliseconds; + _nextRunTimer.Start(); + } + + // ReSharper disable once AsyncVoidEventHandlerMethod + private async void TimerElapsed(object? _, ElapsedEventArgs elapsedEventArgs) + { + try + { + var deviation = elapsedEventArgs.SignalTime.ToUniversalTime() - _expectedNextTime; + + Log.ForContext(_enricher).Debug("[{Service}] Timer elapsed. Deviation is {Deviation}ms", _serviceName, deviation.TotalMilliseconds); + + await _metricService.Emit(Metric.ScheduledService_ScheduleDeviation, deviation.TotalMilliseconds, new Dictionary + { + [ServiceDimension] = _serviceName + }); + } catch (Exception ex) + { + Log.ForContext(_enricher).Error(ex, "Failed to emit timer deviation metric"); + } + + try + { + Stopwatch stopwatch = new(); + + stopwatch.Start(); + await RunAsync(); + stopwatch.Stop(); + + Log.ForContext(_enricher).Debug("[{Service}] Run completed. Total run time was {TotalRuntimeMs}ms", _serviceName, stopwatch.ElapsedMilliseconds); + await _metricService.Emit(Metric.ScheduledService_ServiceRuntime, stopwatch.ElapsedMilliseconds, new Dictionary + { + [ServiceDimension] = _serviceName + }); + } + catch (Exception ex) + { + Log.ForContext(_enricher).Error(ex, "Failed to execute scheduled task."); + } finally + { + ScheduleNext(); + } + } + + /// + /// Initialize the scheduled service. + /// + internal abstract Task Initialize(); + + /// + /// Executes the scheduled service at the scheduled time. + /// + public abstract Task RunAsync(); + + private class Enricher(string serviceName) : ILogEventEnricher + { + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + // Create a log event property for "ProjectSlug". + LogEventProperty logEventProperty = propertyFactory.CreateProperty( + "Service", + serviceName + ); + + // Add the property to the log event if it is not already present. + logEvent.AddPropertyIfAbsent(logEventProperty); + } + } +} \ No newline at end of file diff --git a/InstarBot/Snowflake.cs b/InstarBot/Snowflake.cs index ff4d120..fe39727 100644 --- a/InstarBot/Snowflake.cs +++ b/InstarBot/Snowflake.cs @@ -159,7 +159,7 @@ public static Snowflake Generate() var newIncrement = Interlocked.Increment(ref _increment); // Gracefully handle overflows - if (newIncrement == int.MinValue) + if (newIncrement >= 4096) { newIncrement = 0; Interlocked.Exchange(ref _increment, 0); diff --git a/InstarBot/Utilities.cs b/InstarBot/Utilities.cs index c18747e..66662bf 100644 --- a/InstarBot/Utilities.cs +++ b/InstarBot/Utilities.cs @@ -32,7 +32,7 @@ private static class EnumCache where T : Enum /// A list of attributes of the specified type associated with the enum value; /// or null if no attributes of the specified type are found. /// - public List? GetAttributesOfType() where T : Attribute + public List? GetAttributesOfType() where T : Attribute { var type = enumVal.GetType(); var membersInfo = type.GetMember(enumVal.ToString()); @@ -52,7 +52,7 @@ private static class EnumCache where T : Enum /// The first custom attribute of type if found; /// otherwise, null. /// - public T? GetAttributeOfType() where T : Attribute + public T? GetAttributeOfType() where T : Attribute { var type = enumVal.GetType(); var membersInfo = type.GetMember(enumVal.ToString()); From 2c5b82300db6dd98a059ea67a7fe71efdbe0356e Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sat, 27 Dec 2025 11:26:18 -0800 Subject: [PATCH 11/53] Added notification system, updated AMH commands, refactored some code --- .../Instar.dynamic.test.debug.conf.json | 89 ++++---- InstarBot.Tests.Common/EmbedVerifier.cs | 28 +++ .../AutoMemberSystemCommandTests.cs | 103 ++++----- .../CheckEligibilityCommandTests.cs | 41 +--- .../Interactions/PageCommandTests.cs | 11 +- .../Interactions/ReportUserTests.cs | 33 ++- .../Interactions/ResetBirthdayCommandTests.cs | 7 +- .../Interactions/SetBirthdayCommandTests.cs | 89 +------- .../Services/AutoMemberSystemTests.cs | 108 +++++---- .../Services/BirthdaySystemTests.cs | 1 - .../Services/NotificationSystemTests.cs | 207 ++++++++++++++++++ InstarBot.Tests.Integration/TestTest.cs | 49 ----- .../MockExtensions.cs | 3 +- .../Models/TestChannel.cs | 20 +- .../Models/TestGuild.cs | 4 +- .../Models/TestGuildUser.cs | 15 +- .../Models/TestInteractionContext.cs | 46 +--- .../Models/TestMessage.cs | 25 ++- .../Models/TestRole.cs | 2 +- .../Models/TestSocketUser.cs | 2 + .../Models/TestUser.cs | 5 +- .../Services/TestAutoMemberSystem.cs | 2 + .../Services/TestBirthdaySystem.cs | 2 + .../Services/TestDatabaseService.cs | 66 ++++-- .../Services/TestDiscordService.cs | 5 +- .../Services/TestGaiusAPIService.cs | 45 ++-- .../TestDatabaseContextBuilder.cs | 41 ---- .../TestDiscordContextBuilder.cs | 32 ++- .../TestOrchestrator.cs | 7 +- .../TestServiceProviderBuilder.cs | 22 +- .../TestTimeProvider.cs | 2 +- .../DynamoModels/EventListTests.cs | 6 +- InstarBot/Caching/MemoryCache.cs | 10 +- InstarBot/Commands/AutoMemberHoldCommand.cs | 46 +++- InstarBot/Commands/ReportUserCommand.cs | 26 ++- InstarBot/Commands/SetBirthdayCommand.cs | 7 +- InstarBot/ConfigModels/AutoMemberConfig.cs | 17 +- .../InstarDynamicConfiguration.cs | 9 +- InstarBot/DynamoModels/EventList.cs | 2 + InstarBot/DynamoModels/InstarUserData.cs | 13 +- InstarBot/DynamoModels/Notification.cs | 64 +++++- InstarBot/Embeds/InstarEmbed.cs | 2 + InstarBot/Embeds/NotificationEmbed.cs | 54 +++++ InstarBot/IBuilder.cs | 5 +- InstarBot/IInstarGuild.cs | 4 +- InstarBot/InstarBot.csproj | 1 + InstarBot/Metrics/Metric.cs | 27 ++- InstarBot/Program.cs | 7 +- InstarBot/Services/AWSDynamicConfigService.cs | 30 +-- InstarBot/Services/AutoMemberSystem.cs | 20 ++ InstarBot/Services/BirthdaySystem.cs | 9 +- InstarBot/Services/CloudwatchMetricService.cs | 6 +- InstarBot/Services/DiscordService.cs | 2 +- InstarBot/Services/FileSystemMetricService.cs | 4 +- InstarBot/Services/IDatabaseService.cs | 11 +- InstarBot/Services/IDiscordService.cs | 2 +- InstarBot/Services/IRunnableService.cs | 2 +- InstarBot/Services/IScheduledService.cs | 4 +- InstarBot/Services/IStartableService.cs | 2 +- InstarBot/Services/InstarDynamoDBService.cs | 74 +++++-- InstarBot/Services/NTPService.cs | 71 ++++++ InstarBot/Services/NotificationService.cs | 127 +++++++++-- InstarBot/Snowflake.cs | 2 + InstarBot/Strings.Designer.cs | 20 ++ InstarBot/Strings.resx | 16 +- InstarBot/Utilities.cs | 22 ++ 66 files changed, 1155 insertions(+), 681 deletions(-) create mode 100644 InstarBot.Tests.Integration/Services/NotificationSystemTests.cs delete mode 100644 InstarBot.Tests.Integration/TestTest.cs delete mode 100644 InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs create mode 100644 InstarBot/Embeds/NotificationEmbed.cs create mode 100644 InstarBot/Services/NTPService.cs diff --git a/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json b/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json index 43b8301..0bb682b 100644 --- a/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json +++ b/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json @@ -2,20 +2,23 @@ "Testing": true, "BotName": "Instar", "BotUserID": "1113147583041392641", - "TargetGuild": 985521318080413766, - "TargetChannel": 985521318080413769, - "StaffAnnounceChannel": 985521973113286667, - "StaffRoleID": 793607635608928257, - "NewMemberRoleID": 796052052433698817, - "MemberRoleID": 793611808372031499, + "TargetGuild": "985521318080413766", + "TargetChannel": "985521318080413769", + "StaffAnnounceChannel": "985521973113286667", + "StaffRoleID": "793607635608928257", + "NewMemberRoleID": "796052052433698817", + "MemberRoleID": "793611808372031499", "AuthorizedStaffID": [ - 985521877122428978, - 1113478706250395759, - 793607635608928257 + "985521877122428978", + "1113478706250395759", + "793607635608928257" + ], + "AutoKickRoles": [ + "1454525235398185177" ], "AutoMemberConfig": { - "HoldRole": 966434762032054282, - "IntroductionChannel": 793608456644067348, + "HoldRole": "966434762032054282", + "IntroductionChannel": "793608456644067348", "MinimumJoinAge": 86400, "MinimumMessages": 30, "MinimumMessageTime": 86400, @@ -24,38 +27,38 @@ { "GroupName": "Age", "Roles": [ - 796148749843169330, - 796148761608192051, - 796148761608192051, - 796148788703658044, - 796148799805456426, - 796148820865449984, - 796148842634412033, - 796148857603883038, - 796148869855576064 + "796148749843169330", + "796148761608192051", + "796148761608192051", + "796148788703658044", + "796148799805456426", + "796148820865449984", + "796148842634412033", + "796148857603883038", + "796148869855576064" ] }, { "GroupName": "Gender Identity", "Roles": [ - 796005397436694548, - 796085733437865984, - 796085775199502357, - 796085895399735308, - 815362407237025862, - 796142271555698759, - 796147883937890395, - 815367388442525696 + "796005397436694548", + "796085733437865984", + "796085775199502357", + "796085895399735308", + "815362407237025862", + "796142271555698759", + "796147883937890395", + "815367388442525696" ] }, { "GroupName": "Pronouns", "Roles": [ - 796578609535647765, - 796578878264180736, - 796578922182475836, - 815364127501451264, - 811657756553248828 + "796578609535647765", + "796578878264180736", + "796578922182475836", + "815364127501451264", + "811657756553248828" ] } ] @@ -107,40 +110,40 @@ { "InternalID": "ffcf94e3-3080-455a-82e2-7cd9ec7eaafd", "Name": "Owner", - "ID": 1113478543599468584, - "Teamleader": 130082061988921344, + "ID": "1113478543599468584", + "Teamleader": "130082061988921344", "Color": 16766721, "Priority": 1 }, { "InternalID": "4e484ea5-3cd1-46d4-8fe8-666e34f251ad", "Name": "Admin", - "ID": 1113478785292062800, - "Teamleader": 107839904318271488, + "ID": "1113478785292062800", + "Teamleader": "107839904318271488", "Color": 15132390, "Priority": 2 }, { "InternalID": "9609125a-7e63-4110-8d50-381230ea11b2", "Name": "Moderator", - "ID": 1113478610825773107, - "Teamleader": 346194980408393728, + "ID": "1113478610825773107", + "Teamleader": "346194980408393728", "Color": 15132390, "Priority": 3 }, { "InternalID": "521dce27-9ed9-48fc-9615-dc1d77b72fdd", "Name": "Helper", - "ID": 1113478650768150671, - "Teamleader": 459078815314870283, + "ID": "1113478650768150671", + "Teamleader": "459078815314870283", "Color": 13532979, "Priority": 4 }, { "InternalID": "fe434e9a-2a69-41b6-a297-24e26ba4aebe", "Name": "Community Manager", - "ID": 957411837920567356, - "Teamleader": 340546691491168257, + "ID": "957411837920567356", + "Teamleader": "340546691491168257", "Color": 10892756, "Priority": 5 } diff --git a/InstarBot.Tests.Common/EmbedVerifier.cs b/InstarBot.Tests.Common/EmbedVerifier.cs index f17ac41..f22f65c 100644 --- a/InstarBot.Tests.Common/EmbedVerifier.cs +++ b/InstarBot.Tests.Common/EmbedVerifier.cs @@ -36,29 +36,56 @@ public void AddFieldValue(string value, bool partial = false) _fields.Add((null, value, partial)); } + private void FailTest(string component, string? expected, string? actual) + { + Assert.Fail($""" + + + Failed to match embed {component}. + + Expected: '{expected ?? ""}' + Got: '{actual ?? ""}' + """); + } + private void FailField(string? expectedName, string? expectedValue) + { + Assert.Fail($""" + + + Failed to find a matching embed field. + + Name: '{expectedName ?? ""}' + Value: '{expectedValue ?? ""}' + """); + } + public bool Verify(Embed embed) { if (!VerifyString(Title, embed.Title, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialTitle))) { Log.Error("Failed to match title: Expected '{Expected}', got '{Actual}'", Title, embed.Title); + FailTest("title", Title, embed.Title); return false; } if (!VerifyString(Description, embed.Description, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialDescription))) { Log.Error("Failed to match description: Expected '{Expected}', got '{Actual}'", Description, embed.Description); + FailTest("description", Description, embed.Description); return false; } if (!VerifyString(AuthorName, embed.Author?.Name, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialAuthorName))) { Log.Error("Failed to match author name: Expected '{Expected}', got '{Actual}'", AuthorName, embed.Author?.Name); + FailTest("author", AuthorName, embed.Author?.Name); return false; } if (!VerifyString(FooterText, embed.Footer?.Text, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialFooterText))) { Log.Error("Failed to match footer text: Expected '{Expected}', got '{Actual}'", FooterText, embed.Footer?.Text); + FailTest("footer", FooterText, embed.Footer?.Text); return false; } @@ -73,6 +100,7 @@ private bool VerifyFields(ImmutableArray embedFields) if (!embedFields.Any(n => VerifyString(name, n.Name, partial) && VerifyString(value, n.Value, partial))) { Log.Error("Failed to match field: Expected Name '{ExpectedName}', Value '{ExpectedValue}'", name, value); + FailField(name, value); return false; } } diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs index ead5e32..0c5ef3e 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs @@ -15,66 +15,6 @@ namespace InstarBot.Tests.Integration.Interactions; public static class AutoMemberSystemCommandTests { - private const ulong NewMemberRole = 796052052433698817ul; - private const ulong MemberRole = 793611808372031499ul; - - /* - private static async Task Setup(bool setupAMH = false) - { - TestUtilities.SetupLogging(); - - // This is going to be annoying - var userID = Snowflake.Generate(); - var modID = Snowflake.Generate(); - - var mockDDB = new MockDatabaseService(); - var mockMetrics = new MockMetricService(); - - - - var user = new TestGuildUser - { - Id = userID, - Username = "username", - JoinedAt = DateTimeOffset.UtcNow, - RoleIds = [ NewMemberRole ] - }; - - var mod = new TestGuildUser - { - Id = modID, - Username = "mod_username", - JoinedAt = DateTimeOffset.UtcNow, - RoleIds = [MemberRole] - }; - - await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(user)); - await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(mod)); - - if (setupAMH) - { - var ddbRecord = await mockDDB.GetUserAsync(userID); - ddbRecord.Should().NotBeNull(); - ddbRecord.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord - { - Date = DateTime.UtcNow, - ModeratorID = modID, - Reason = "test reason" - }; - await ddbRecord.CommitAsync(); - } - - var commandMock = TestUtilities.SetupCommandMock( - () => new AutoMemberHoldCommand(mockDDB, TestUtilities.GetDynamicConfiguration(), mockMetrics, TimeProvider.System), - new TestContext - { - UserID = modID - }); - - return new Context(mockDDB, mockMetrics, user, mod, commandMock); - } - */ - [Fact] public static async Task HoldMember_WithValidUserAndReason_ShouldCreateRecord() { @@ -93,6 +33,18 @@ public static async Task HoldMember_WithValidUserAndReason_ShouldCreateRecord() record.Data.AutoMemberHoldRecord.Should().NotBeNull(); record.Data.AutoMemberHoldRecord.ModeratorID.ID.Should().Be(orchestrator.Actor.Id); record.Data.AutoMemberHoldRecord.Reason.Should().Be("Test reason"); + + if (orchestrator.Database is not TestDatabaseService tds) + throw new InvalidOperationException("Expected orchestrator.Database to be TestDatabaseService!"); + + var notifications = tds.GetAllNotifications(); + notifications.Should().ContainSingle(); + + var notification = notifications.First(); + notification.Should().NotBeNull(); + + notification.Type.Should().Be(NotificationType.AutoMemberHold); + notification.ReferenceUser!.ID.Should().Be(orchestrator.Subject.Id); } [Fact] @@ -149,7 +101,7 @@ public static async Task HoldMember_WithDynamoDBError_ShouldRespondWithError() if (orchestrator.Database is not IMockOf dbMock) throw new InvalidOperationException("This test depends on the registered database implementing IMockOf"); - dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(n => n.Id == orchestrator.Subject.Id))).Throws(); + dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(guildUser => guildUser.Id == orchestrator.Subject.Id))).Throws(); // Act await cmd.Object.HoldMember(orchestrator.Subject, "Test reason"); @@ -173,6 +125,18 @@ public static async Task UnholdMember_WithValidUser_ShouldRemoveAMH() await orchestrator.CreateAutoMemberHold(orchestrator.Subject); var cmd = orchestrator.GetCommand(); + if (orchestrator.Database is not TestDatabaseService tds) + throw new InvalidOperationException("Expected orchestrator.Database to be TestDatabaseService!"); + + await tds.CreateNotificationAsync(new Notification + { + Type = NotificationType.AutoMemberHold, + ReferenceUser = orchestrator.Subject.Id, + Date = (orchestrator.TimeProvider.GetUtcNow() + TimeSpan.FromDays(7)).DateTime + }); + + tds.GetAllNotifications().Should().ContainSingle(); + // Act await cmd.Object.UnholdMember(orchestrator.Subject); @@ -182,6 +146,21 @@ public static async Task UnholdMember_WithValidUser_ShouldRemoveAMH() var afterRecord = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); afterRecord.Should().NotBeNull(); afterRecord.Data.AutoMemberHoldRecord.Should().BeNull(); + + // There is a potential asynchronous delay here, so let's keep waiting for this condition for 5 seconds. + await Task.WhenAny( + Task.Delay(5000), + Task.Factory.StartNew(async () => + { + while (true) + { + if (tds.GetAllNotifications().Count == 0) + break; + + // only poll once every 50ms + await Task.Delay(50); + } + })); } [Fact] @@ -223,7 +202,7 @@ public static async Task UnholdMember_WithDynamoError_ShouldReturnError() if (orchestrator.Database is not IMockOf dbMock) throw new InvalidOperationException("This test depends on the registered database implementing IMockOf"); - dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(n => n.Id == orchestrator.Subject.Id))).Throws(); + dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(guildUser => guildUser.Id == orchestrator.Subject.Id))).Throws(); // Act await cmd.Object.UnholdMember(orchestrator.Subject); diff --git a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs index 14e38b9..6e3d2c1 100644 --- a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs @@ -14,30 +14,13 @@ namespace InstarBot.Tests.Integration.Interactions; public static class CheckEligibilityCommandTests { - private const ulong MemberRole = 793611808372031499ul; - private const ulong NewMemberRole = 796052052433698817ul; - - private static async Task SetupOrchestrator(MembershipEligibility eligibility) + private static TestOrchestrator SetupOrchestrator(MembershipEligibility eligibility) { var orchestrator = TestOrchestrator.Default; TestAutoMemberSystem tams = (TestAutoMemberSystem) orchestrator.GetService(); tams.Mock.Setup(n => n.CheckEligibility(It.IsAny(), It.IsAny())).Returns(eligibility); - /*if (context.IsAMH) - { - var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Actor.Id); - dbUser.Should().NotBeNull(); - dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord - { - Date = DateTime.UtcNow, - ModeratorID = Snowflake.Generate(), - Reason = "Testing" - }; - - await dbUser.CommitAsync(); - }*/ - return orchestrator; } @@ -53,7 +36,7 @@ private static EmbedVerifier.VerifierBuilder CreateVerifier() public static async Task CheckEligibilityCommand_WithExistingMember_ShouldEmitValidMessage() { // Arrange - var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var orchestrator = SetupOrchestrator(MembershipEligibility.Eligible); var cmd = orchestrator.GetCommand(); await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.MemberRoleID); @@ -69,7 +52,7 @@ public static async Task CheckEligibilityCommand_WithExistingMember_ShouldEmitVa public static async Task CheckEligibilityCommand_NoMemberRoles_ShouldEmitValidErrorMessage() { // Arrange - var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var orchestrator = SetupOrchestrator(MembershipEligibility.Eligible); var cmd = orchestrator.GetCommand(); // Act @@ -88,7 +71,7 @@ public static async Task CheckEligibilityCommand_Eligible_ShouldEmitValidMessage .WithDescription(Strings.Command_CheckEligibility_MessagesEligibility) .Build(); - var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var orchestrator = SetupOrchestrator(MembershipEligibility.Eligible); var cmd = orchestrator.GetCommand(); await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); @@ -112,7 +95,7 @@ public static async Task CheckEligibilityCommand_WithNewMemberAMH_ShouldEmitVali .WithField(Strings.Command_CheckEligibility_AMH_ContactStaff) .Build(); - var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var orchestrator = SetupOrchestrator(MembershipEligibility.Eligible); var cmd = orchestrator.GetCommand(); await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); @@ -145,7 +128,7 @@ public static async Task CheckEligibilityCommand_DDBError_ShouldEmitValidMessage .WithDescription(Strings.Command_CheckEligibility_MessagesEligibility) .Build(); - var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var orchestrator = SetupOrchestrator(MembershipEligibility.Eligible); var cmd = orchestrator.GetCommand(); await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); @@ -199,7 +182,7 @@ public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMes .WithField(testFieldMap, true) .Build(); - var orchestrator = await SetupOrchestrator(eligibility); + var orchestrator = SetupOrchestrator(eligibility); var cmd = orchestrator.GetCommand(); await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); @@ -216,7 +199,7 @@ public static async Task CheckOtherEligibility_WithEligibleMember_ShouldEmitVali { // Arrange - var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var orchestrator = SetupOrchestrator(MembershipEligibility.Eligible); var cmd = orchestrator.GetCommand(); await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); @@ -237,7 +220,7 @@ public static async Task CheckOtherEligibility_WithEligibleMember_ShouldEmitVali public static async Task CheckOtherEligibility_WithIneligibleMember_ShouldEmitValidEmbed() { // Arrange - var orchestrator = await SetupOrchestrator(MembershipEligibility.MissingRoles); + var orchestrator = SetupOrchestrator(MembershipEligibility.MissingRoles); var cmd = orchestrator.GetCommand(); await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); @@ -258,7 +241,7 @@ public static async Task CheckOtherEligibility_WithIneligibleMember_ShouldEmitVa public static async Task CheckOtherEligibility_WithAMHedMember_ShouldEmitValidEmbed() { // Arrange - var orchestrator = await SetupOrchestrator(MembershipEligibility.MissingRoles); + var orchestrator = SetupOrchestrator(MembershipEligibility.MissingRoles); var cmd = orchestrator.GetCommand(); var verifier = CreateVerifier() @@ -292,13 +275,13 @@ public static async Task CheckOtherEligibility_WithAMHedMember_ShouldEmitValidEm public static async Task CheckOtherEligibility_WithDynamoError_ShouldEmitValidEmbed() { // Arrange - var orchestrator = await SetupOrchestrator(MembershipEligibility.MissingRoles); + var orchestrator = SetupOrchestrator(MembershipEligibility.MissingRoles); var cmd = orchestrator.GetCommand(); if (orchestrator.Database is not IMockOf dbMock) throw new InvalidOperationException("This test depends on the registered database implementing IMockOf"); - dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(n => n.Id == orchestrator.Subject.Id))).Throws(); + dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(guildUser => guildUser.Id == orchestrator.Subject.Id))).Throws(); var verifier = CreateVerifier() .WithDescription(Strings.Command_Eligibility_IneligibleText) diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index 77cba58..6550506 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -23,15 +23,15 @@ private static async Task SetupOrchestrator(PageCommandTestCon return orchestrator; } - - public static async IAsyncEnumerable GetTeams(TestOrchestrator orchestrator, PageTarget pageTarget) - { + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + private static async IAsyncEnumerable GetTeams(TestOrchestrator orchestrator, PageTarget pageTarget) + { var teamsConfig = orchestrator.Configuration.Teams.ToDictionary(n => n.InternalID, n => n); teamsConfig.Should().NotBeNull(); - var teamRefs = pageTarget.GetAttributesOfType()?.Select(n => n.InternalID) ?? - []; + var teamRefs = pageTarget.GetAttributesOfType()?.Select(n => n.InternalID) ?? []; foreach (var internalId in teamRefs) { @@ -41,6 +41,7 @@ public static async IAsyncEnumerable GetTeams(TestOrchestrator orchestrato yield return value; } } +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously private static Snowflake GetTeamRole(TestOrchestrator orchestrator, PageTarget owner) { diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs index e4044ec..d0988d0 100644 --- a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -1,5 +1,4 @@ using Discord; -using FluentAssertions; using InstarBot.Test.Framework; using InstarBot.Test.Framework.Models; using Moq; @@ -12,7 +11,7 @@ namespace InstarBot.Tests.Integration.Interactions; public static class ReportUserTests { - private static async Task SetupOrchestrator(ReportContext context) + private static TestOrchestrator SetupOrchestrator(ReportContext context) { var orchestrator = TestOrchestrator.Default; @@ -29,7 +28,7 @@ public static async Task ReportUser_WhenReportingNormally_ShouldNotifyStaff() .WithReason("This is a test report") .Build(); - var orchestrator = await SetupOrchestrator(context); + var orchestrator = SetupOrchestrator(context); var command = orchestrator.GetCommand(); var verifier = EmbedVerifier.Builder() @@ -42,7 +41,7 @@ public static async Task ReportUser_WhenReportingNormally_ShouldNotifyStaff() .Build(); // Act - await command.Object.HandleCommand(SetupMessageCommandMock(orchestrator, context)); + await command.Object.HandleCommand(await SetupMessageCommandMock(orchestrator, context)); await command.Object.ModalResponse(new ReportMessageModal { ReportReason = context.Reason @@ -50,8 +49,11 @@ await command.Object.ModalResponse(new ReportMessageModal // Assert command.VerifyResponse(Strings.Command_ReportUser_ReportSent, true); - - ((TestChannel) await orchestrator.Discord.GetChannel(orchestrator.Configuration.StaffAnnounceChannel)).VerifyEmbed(verifier, "{@}"); + + if (await orchestrator.Discord.GetChannel(orchestrator.Configuration.StaffAnnounceChannel) is not TestChannel testChannel) + throw new InvalidOperationException("Channel was not TestChannel"); + + testChannel.VerifyEmbed(verifier, "{@}"); } [Fact(DisplayName = "Report user function times out if cache expires")] @@ -62,11 +64,11 @@ public static async Task ReportUser_WhenReportingNormally_ShouldFail_IfNotComple .WithReason("This is a test report") .Build(); - var orchestrator = await SetupOrchestrator(context); + var orchestrator = SetupOrchestrator(context); var command = orchestrator.GetCommand(); // Act - await command.Object.HandleCommand(SetupMessageCommandMock(orchestrator, context)); + await command.Object.HandleCommand(await SetupMessageCommandMock(orchestrator, context)); ReportUserCommand.PurgeCache(); await command.Object.ModalResponse(new ReportMessageModal { @@ -77,9 +79,11 @@ await command.Object.ModalResponse(new ReportMessageModal command.VerifyResponse(Strings.Command_ReportUser_ReportExpired, true); } - private static IInstarMessageCommandInteraction SetupMessageCommandMock(TestOrchestrator orchestrator, ReportContext context) - { - TestChannel testChannel = (TestChannel) orchestrator.Guild.GetTextChannel(context.Channel); + private static async Task SetupMessageCommandMock(TestOrchestrator orchestrator, ReportContext context) + { + if (await orchestrator.Discord.GetChannel(context.Channel) is not TestChannel testChannel) + throw new InvalidOperationException("Channel was not TestChannel"); + var message = testChannel.AddMessage(orchestrator.Subject, "Naughty message"); var socketMessageDataMock = new Mock(); @@ -99,12 +103,7 @@ private static IInstarMessageCommandInteraction SetupMessageCommandMock(TestOrch private record ReportContext(Snowflake Channel, string Reason) { - public static ReportContextBuilder Builder() - { - return new ReportContextBuilder(); - } - - public Embed? ResultEmbed { get; set; } + public static ReportContextBuilder Builder() => new(); } private class ReportContextBuilder diff --git a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs index e7c1c32..4552f30 100644 --- a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs @@ -1,15 +1,10 @@ -using Amazon.SimpleSystemsManagement.Model; -using Discord; -using FluentAssertions; +using FluentAssertions; using InstarBot.Test.Framework; using InstarBot.Test.Framework.Services; -using Microsoft.Extensions.DependencyInjection; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; -using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.DynamoModels; -using PaxAndromeda.Instar.Services; using Xunit; using Assert = Xunit.Assert; diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs index 1b19bc0..44313e6 100644 --- a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -1,14 +1,9 @@ using Discord; using FluentAssertions; using InstarBot.Test.Framework; -using InstarBot.Test.Framework.Models; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Testing.Platform.Extensions.Messages; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; -using PaxAndromeda.Instar.ConfigModels; -using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; using Xunit; @@ -16,71 +11,6 @@ namespace InstarBot.Tests.Integration.Interactions; public static class SetBirthdayCommandTests { - /*private static async Task<(IDatabaseService, Mock, InstarDynamicConfiguration)> SetupOrchestrator(SetBirthdayContext context, DateTime? timeOverride = null, bool throwError = false) - { - TestUtilities.SetupLogging(); - - var timeProvider = TimeProvider.System; - if (timeOverride is not null) - { - var timeProviderMock = new Mock(); - timeProviderMock.Setup(n => n.GetUtcNow()).Returns(new DateTimeOffset((DateTime) timeOverride)); - timeProvider = timeProviderMock.Object; - } - - var staffAnnounceChannelMock = new Mock(); - var birthdayAnnounceChannelMock = new Mock(); - context.StaffAnnounceChannel = staffAnnounceChannelMock; - context.BirthdayAnnounceChannel = birthdayAnnounceChannelMock; - - var ddbService = TestUtilities.GetServices().GetService(); - var cfgService = TestUtilities.GetDynamicConfiguration(); - var cfg = await cfgService.GetConfig(); - - var guildMock = new Mock(); - guildMock.Setup(n => n.GetTextChannel(cfg.StaffAnnounceChannel)).Returns(staffAnnounceChannelMock.Object); - guildMock.Setup(n => n.GetTextChannel(cfg.BirthdayConfig.BirthdayAnnounceChannel)).Returns(birthdayAnnounceChannelMock.Object); - - var testContext = new TestContext - { - UserID = context.UserID.ID, - Channels = - { - { cfg.StaffAnnounceChannel, staffAnnounceChannelMock.Object }, - { cfg.BirthdayConfig.BirthdayAnnounceChannel, birthdayAnnounceChannelMock.Object } - } - }; - - var discord = TestUtilities.SetupDiscordService(testContext); - if (discord is MockDiscordService mockDiscord) - { - mockDiscord.Guild = guildMock.Object; - } - - var birthdaySystem = new BirthdaySystem(cfgService, discord, ddbService, new MockMetricService(), timeProvider); - - if (throwError && ddbService is MockDatabaseService mockDDB) - mockDDB.Setup(n => n.GetOrCreateUserAsync(It.IsAny())).Throws(); - - var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService, TestUtilities.GetDynamicConfiguration(), new MockMetricService(), birthdaySystem, timeProvider), testContext); - - await cmd.Object.Context.User!.AddRoleAsync(cfg.NewMemberRoleID); - - cmd.Setup(n => n.Context.User!.GuildId).Returns(TestUtilities.GuildID); - - context.User = cmd.Object.Context.User!; - - cmd.Setup(n => n.Context.Guild).Returns(guildMock.Object); - - ((MockDatabaseService) ddbService).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); - - ddbService.Should().NotBeNull(); - - return (ddbService, cmd, cfg); - }*/ - - private const ulong NewMemberRole = 796052052433698817ul; - private static async Task SetupOrchestrator(bool throwError = false) { var orchestrator = TestOrchestrator.Default; @@ -145,19 +75,12 @@ public static async Task SetBirthdayCommand_WithInvalidDate_ShouldReturnError(in await cmd.Object.SetBirthday((Month)month, day, year); // Assert - if (month is < 0 or > 12) - { - cmd.VerifyResponse(Strings.Command_SetBirthday_MonthsOutOfRange, true); - } - else - { - var date = new DateTime(year, month, 1); // there's always a 1st of the month - var daysInMonth = DateTime.DaysInMonth(year, month); - - // Assert - cmd.VerifyResponse(Strings.Command_SetBirthday_DaysInMonthOutOfRange, true); - } - } + cmd.VerifyResponse( + month is < 0 or > 12 + ? Strings.Command_SetBirthday_MonthsOutOfRange + // Assert + : Strings.Command_SetBirthday_DaysInMonthOutOfRange, true); + } [Fact(DisplayName = "Attempting to set a birthday in the future should emit an error message.")] public static async Task SetBirthdayCommand_WithDateInFuture_ShouldReturnError() diff --git a/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs index aceaef1..c4cca37 100644 --- a/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs +++ b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs @@ -2,16 +2,13 @@ using InstarBot.Test.Framework; using InstarBot.Test.Framework.Models; using InstarBot.Test.Framework.Services; -using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using PaxAndromeda.Instar; -using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Gaius; using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Services; using Xunit; -using static Microsoft.ApplicationInsights.MetricDimensionNames.TelemetryContext; namespace InstarBot.Tests.Integration.Services; @@ -41,19 +38,24 @@ private static async Task SetupOrchestrator(AutoMemberSystemCo if (context.PostedIntroduction) { - TestChannel introChannel = (TestChannel) await orchestrator.Discord.GetChannel(orchestrator.Configuration.AutoMemberConfig.IntroductionChannel); + if (await orchestrator.Discord.GetChannel(orchestrator.Configuration.AutoMemberConfig.IntroductionChannel) is not TestChannel introChannel) + throw new InvalidOperationException("Introduction channel was not TestChannel"); + introChannel.AddMessage(orchestrator.Subject, "Some introduction"); } for (var i = 0; i < context.MessagesLast24Hours; i++) channel.AddMessage(orchestrator.Subject, "Some text"); + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); + if (dbUser is null) + throw new InvalidOperationException($"Database entry for user {orchestrator.Subject.Id} did not automatically populate"); + if (context.GrantedMembershipBefore) - { - var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); dbUser.Data.Position = InstarUserPosition.Member; - await dbUser.CommitAsync(); - } + + dbUser.Data.Joined = (orchestrator.TimeProvider.GetUtcNow() - TimeSpan.FromHours(context.FirstJoinTime)).UtcDateTime; + await dbUser.CommitAsync(); if (context.GaiusInhibited) { @@ -267,7 +269,6 @@ public static async Task AutoMemberSystem_UserWithGaiusWarning_ShouldNotBeGrante .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() - .HasBeenWarned() .WithMessages(100) .Build(); @@ -300,7 +301,6 @@ public static async Task AutoMemberSystem_UserWithGaiusCaselog_ShouldNotBeGrante .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() - .HasBeenPunished() .WithMessages(100) .Build(); @@ -334,7 +334,6 @@ public static async Task AutoMemberSystem_UserWithJoinAgeKick_ShouldBeGrantedMem .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() - .HasBeenPunished(true) .WithMessages(100) .Build(); @@ -396,7 +395,6 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe .Build(); var orchestrator = await SetupOrchestrator(context); - var ams = orchestrator.GetService(); // Act var service = orchestrator.Discord as TestDiscordService; @@ -409,7 +407,7 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe // Assert context.AssertMember(orchestrator); - } + } [Fact(DisplayName = "The Dynamo record for a user should be updated when the user's username changes")] public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflectedInDynamo() @@ -419,7 +417,6 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(1)) - .FirstJoined(TimeSpan.FromDays(7)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() .HasBeenGrantedMembershipBefore() @@ -427,7 +424,6 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte .Build(); var orchestrator = await SetupOrchestrator(context); - var ams = orchestrator.GetService(); // Make sure the user is in the database await orchestrator.Database.CreateUserAsync(InstarUserData.CreateFrom(orchestrator.Subject)); @@ -451,22 +447,70 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte ddbUser.Data.Usernames.Should().Contain(n => n.Data != null && n.Data.Equals(newUsername, StringComparison.Ordinal)); } + [Fact(DisplayName = "The Dynamo record for a user should be updated when the user's username changes")] + public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldKickWithForbiddenRole() + { + // Arrange + const string newUsername = "fred"; + + var context = AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(1)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .HasBeenGrantedMembershipBefore() + .WithMessages(100) + .Build(); + + var orchestrator = await SetupOrchestrator(context); + + // Make sure the user is in the database + await orchestrator.Database.CreateUserAsync(InstarUserData.CreateFrom(orchestrator.Subject)); + + // Act + var mds = (TestDiscordService) orchestrator.Discord; + + var newUser = orchestrator.Subject.Clone(); + newUser.Username = newUsername; + + var autoKickRole = orchestrator.Configuration.AutoKickRoles?.First() ?? throw new BadStateException("This test expects AutoKickRoles to be set"); + await newUser.AddRoleAsync(autoKickRole); + + newUser.RoleIds.Should().Contain(autoKickRole); + + await mds.TriggerUserUpdated(new UserUpdatedEventArgs(orchestrator.Subject.Id, orchestrator.Subject, newUser)); + + // Assert + var ddbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); + + ddbUser.Should().NotBeNull(); + ddbUser.Data.Username.Should().Be(newUsername); + + ddbUser.Data.Usernames.Should().NotBeNull(); + ddbUser.Data.Usernames.Count.Should().Be(2); + ddbUser.Data.Usernames.Should().Contain(n => n.Data != null && n.Data.Equals(newUsername, StringComparison.Ordinal)); + + newUser.Mock.Verify(n => n.KickAsync(It.IsAny())); + } + [Fact(DisplayName = "A user should be created in DynamoDB if they're eligible for membership but missing in DDB")] public static async Task AutoMemberSystem_MemberEligibleButMissingInDDB_ShouldBeCreatedAndGrantedMembership() { - // Arrange var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() .WithMessages(100) - .SuppressDDBEntry() .Build(); var orchestrator = await SetupOrchestrator(context); var ams = orchestrator.GetService(); + if (orchestrator.Database is not TestDatabaseService tbs) + throw new InvalidOperationException("Database was not TestDatabaseService"); + + tbs.DeleteUser(orchestrator.Subject.Id); + // Act await ams.RunAsync(); @@ -481,7 +525,6 @@ private record AutoMemberSystemContext( int MessagesLast24Hours, int FirstJoinTime, bool GrantedMembershipBefore, - bool SuppressDDBEntry, bool GaiusInhibited) { public static AutoMemberSystemContextBuilder Builder() => new(); @@ -514,12 +557,8 @@ private class AutoMemberSystemContextBuilder private bool _postedIntroduction; private int _messagesLast24Hours; private bool _gaiusAvailable = true; - private bool _gaiusPunished; - private bool _joinAgeKick; - private bool _gaiusWarned; - private int _firstJoinTime; private bool _grantedMembershipBefore; - private bool _suppressDDB; + private int _firstJoinTime; public AutoMemberSystemContextBuilder Joined(TimeSpan timeAgo) @@ -551,21 +590,7 @@ public AutoMemberSystemContextBuilder InhibitGaius() _gaiusAvailable = false; return this; } - - public AutoMemberSystemContextBuilder HasBeenPunished(bool isJoinAgeKick = false) - { - _gaiusPunished = true; - _joinAgeKick = isJoinAgeKick; - - return this; - } - - public AutoMemberSystemContextBuilder HasBeenWarned() - { - _gaiusWarned = true; - return this; - } - + public AutoMemberSystemContextBuilder FirstJoined(TimeSpan hoursAgo) { _firstJoinTime = (int) Math.Round(hoursAgo.TotalHours); @@ -578,12 +603,6 @@ public AutoMemberSystemContextBuilder HasBeenGrantedMembershipBefore() return this; } - public AutoMemberSystemContextBuilder SuppressDDBEntry() - { - _suppressDDB = true; - return this; - } - public AutoMemberSystemContext Build() { return new AutoMemberSystemContext( @@ -593,7 +612,6 @@ public AutoMemberSystemContext Build() _messagesLast24Hours, _firstJoinTime, _grantedMembershipBefore, - _suppressDDB, _gaiusAvailable); } } diff --git a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs index 3ced526..f1accf2 100644 --- a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs +++ b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs @@ -3,7 +3,6 @@ using InstarBot.Test.Framework.Models; using Moq; using PaxAndromeda.Instar; -using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.Services; using Xunit; using Metric = PaxAndromeda.Instar.Metrics.Metric; diff --git a/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs b/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs new file mode 100644 index 0000000..941bc4d --- /dev/null +++ b/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs @@ -0,0 +1,207 @@ +using Discord; +using FluentAssertions; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Metrics; +using PaxAndromeda.Instar.Services; +using Xunit; + +namespace InstarBot.Tests.Integration.Services; + +public static class NotificationSystemTests +{ + private static async Task SetupOrchestrator() + { + return await TestOrchestrator.Builder + .WithService() + .Build(); + } + + private static Notification CreateNotification(TestOrchestrator orchestrator) + { + return new Notification + { + Actor = orchestrator.Actor.Id, + Channel = orchestrator.Configuration.StaffAnnounceChannel, + Date = (orchestrator.TimeProvider.GetUtcNow() - TimeSpan.FromMinutes(5)).UtcDateTime, + GuildID = orchestrator.GuildID, + Priority = NotificationPriority.Normal, + Subject = "Some test subject", + Targets = [ + new NotificationTarget { Id = orchestrator.Configuration.StaffRoleID, Type = NotificationTargetType.Role } + ], + Data = new NotificationData + { + Message = "This is a test notification", + Fields = [ + new NotificationEmbedField + { + Name = "Some field", + Value = "Some value" + } + ] + } + }; + } + + private static EmbedVerifier CreateVerifierFromNotification(TestOrchestrator orchestrator, Notification notification) + { + var verifier = EmbedVerifier.Builder() + .WithAuthorName(orchestrator.Actor.Username) + .WithTitle(notification.Subject) + .WithDescription(notification.Data.Message); + + if (notification.Data.Fields is not null) + verifier = notification.Data.Fields.Aggregate(verifier, (current, field) => current.WithField(field.Name, field.Value)); + + return verifier.Build(); + } + + [Fact] + public static async Task NotificationSystem_ShouldEmitNothing_WhenNoNotificationsArePresent() + { + // Arrange + var orchestrator = await SetupOrchestrator(); + var service = orchestrator.GetService(); + + // Act + await service.RunAsync(); + + // Assert + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsSent).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsFailed).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_MalformedNotification).Sum().Should().Be(0); + } + + [Fact] + public static async Task NotificationSystem_ShouldPostEmbed_WithPendingNotification() + { + // Arrange + var orchestrator = await SetupOrchestrator(); + var service = orchestrator.GetService(); + + if (orchestrator.Actor is not TestGuildUser tgu) + throw new InvalidOperationException("Actor is not a TestGuildUser"); + + tgu.Username = "username"; + + var notification = CreateNotification(orchestrator); + var verifier = CreateVerifierFromNotification(orchestrator, notification); + var channel = orchestrator.GetChannel(orchestrator.Configuration.StaffAnnounceChannel); + + // Populate the database + await orchestrator.Database.CreateNotificationAsync(notification); + (await orchestrator.Database.GetPendingNotifications()).Count.Should().Be(1); + + // Act + await service.RunAsync(); + + // Assert + channel.VerifyEmbed(verifier, "<@&{0}>"); + + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsSent).Sum().Should().Be(1); + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsFailed).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_MalformedNotification).Sum().Should().Be(0); + + (await orchestrator.Database.GetPendingNotifications()).Count.Should().Be(0); + } + + [Fact] + public static async Task NotificationSystem_ShouldDoNothing_WithNotificationPendingForAnotherGuild() + { + // Arrange + var orchestrator = await SetupOrchestrator(); + var service = orchestrator.GetService(); + + var notification = CreateNotification(orchestrator); + notification.GuildID = Snowflake.Generate(); + + // Populate the database + await orchestrator.Database.CreateNotificationAsync(notification); + (await orchestrator.Database.GetPendingNotifications()).Count.Should().Be(0); + + // Act + await service.RunAsync(); + + // Assert + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsSent).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsFailed).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_MalformedNotification).Sum().Should().Be(0); + + (await orchestrator.Database.GetPendingNotifications()).Count.Should().Be(0); + } + + [Fact] + public static async Task NotificationSystem_ShouldEmitError_WithBadChannel() + { + // Arrange + var orchestrator = await SetupOrchestrator(); + var service = orchestrator.GetService(); + + var notification = CreateNotification(orchestrator); + // Override the channel + notification.Channel = new Snowflake(Snowflake.Epoch); + + + // Populate the database + await orchestrator.Database.CreateNotificationAsync(notification); + (await orchestrator.Database.GetPendingNotifications()).Count.Should().Be(1); + + // Act + await service.RunAsync(); + + // Assert + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsSent).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsFailed).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_MalformedNotification).Sum().Should().Be(1); + + (await orchestrator.Database.GetPendingNotifications()).Count.Should().Be(0); + } + + [Fact] + public static async Task NotificationSystem_ShouldEmitError_WithFailedMessageSend() + { + // Arrange + var orchestrator = await SetupOrchestrator(); + var service = orchestrator.GetService(); + + var notification = CreateNotification(orchestrator); + + if (orchestrator.GetChannel(orchestrator.Configuration.StaffAnnounceChannel) is not IMockOf textChannelMock) + throw new InvalidOperationException($"Expected channel {orchestrator.Configuration.StaffAnnounceChannel.ID} to be IMockOf"); + + textChannelMock.Mock.Setup(n => n.SendMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())).Throws(); + + + // Populate the database + await orchestrator.Database.CreateNotificationAsync(notification); + (await orchestrator.Database.GetPendingNotifications()).Count.Should().Be(1); + + // Act + await service.RunAsync(); + + // Assert + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsSent).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsFailed).Sum().Should().Be(1); + orchestrator.Metrics.GetMetricValues(Metric.Notification_MalformedNotification).Sum().Should().Be(0); + + var pendingNotifications = await orchestrator.Database.GetPendingNotifications(); + pendingNotifications.Count.Should().Be(1); + pendingNotifications.First().Should().NotBeNull(); + pendingNotifications.First().Data.SendAttempts.Should().Be(1); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/TestTest.cs b/InstarBot.Tests.Integration/TestTest.cs deleted file mode 100644 index 73c571a..0000000 --- a/InstarBot.Tests.Integration/TestTest.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Discord; -using FluentAssertions; -using InstarBot.Test.Framework; -using InstarBot.Test.Framework.Models; -using Moq; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Commands; -using PaxAndromeda.Instar.Services; -using Xunit; - -namespace InstarBot.Tests.Integration; - -public class TestTest -{ - private const ulong NewMemberRole = 796052052433698817ul; - - private static async Task<(TestOrchestrator, Mock)> SetupMocks2(DateTimeOffset? timeOverride = null, bool throwError = false) - { - var orchestrator = await TestOrchestrator.Builder - .WithTime(timeOverride) - .WithService() - .Build(); - - await orchestrator.Actor.AddRoleAsync(NewMemberRole); - - // yoooo, this is going to be so nice if this all works - var cmd = orchestrator.GetCommand(); - - if (throwError) - { - if (orchestrator.GetService() is not IMockOf ddbService) - throw new InvalidOperationException("IDatabaseService was not mocked correctly."); - - ddbService.Mock.Setup(n => n.GetOrCreateUserAsync(It.IsAny())).Throws(); - } - - return (orchestrator, cmd); - } - - [Fact] - public async Task Test() - { - var (orchestrator, mock) = await SetupMocks2(DateTimeOffset.UtcNow); - - var guildUser = orchestrator.Actor as IGuildUser; - - guildUser.RoleIds.Should().Contain(NewMemberRole); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/MockExtensions.cs b/InstarBot.Tests.Orchestrator/MockExtensions.cs index d29e7fe..f6c3854 100644 --- a/InstarBot.Tests.Orchestrator/MockExtensions.cs +++ b/InstarBot.Tests.Orchestrator/MockExtensions.cs @@ -1,4 +1,5 @@ using Discord; +using Discord.Interactions; using InstarBot.Tests; using Moq; using Moq.Protected; @@ -38,7 +39,7 @@ public void VerifyMessage(string format, bool partial = false) /// /// Verifies that the command responded to the user with an embed that satisfies the specified . /// - /// The type of command. Must implement . + /// The type of command. Must implement . /// An instance to verify against. /// An optional message format, if present. Defaults to null. /// An optional flag indicating whether partial matches are acceptable. Defaults to false. diff --git a/InstarBot.Tests.Orchestrator/Models/TestChannel.cs b/InstarBot.Tests.Orchestrator/Models/TestChannel.cs index d5edfc0..d52cee2 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestChannel.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestChannel.cs @@ -3,7 +3,6 @@ using Moq; using PaxAndromeda.Instar; using System.Diagnostics.CodeAnalysis; -using InvalidOperationException = Amazon.CloudWatchLogs.Model.InvalidOperationException; using MessageProperties = Discord.MessageProperties; #pragma warning disable CS1998 @@ -12,23 +11,17 @@ namespace InstarBot.Test.Framework.Models; [SuppressMessage("ReSharper", "ReplaceAutoPropertyWithComputedProperty")] -public sealed class TestChannel : IMockOf, ITextChannel +public sealed class TestChannel (Snowflake id) : IMockOf, ITextChannel { - public ulong Id { get; } - public DateTimeOffset CreatedAt { get; } + public ulong Id { get; } = id; + public DateTimeOffset CreatedAt { get; } = id.Time; - public Mock Mock { get; } = new(); + public Mock Mock { get; } = new(); private readonly Dictionary _messages = new(); public IEnumerable Messages => _messages.Values; - public TestChannel(Snowflake id) - { - Id = id; - CreatedAt = id.Time; - } - public void VerifyMessage(string format, bool partial = false) { Mock.Verify(c => c.SendMessageAsync( @@ -250,7 +243,8 @@ public Task SendFilesAsync(IEnumerable attachments public Task ModifyAsync(Action func, RequestOptions options = null) { - return ((IGuildChannel)Mock).ModifyAsync(func, options); + // ReSharper disable once SuspiciousTypeConversion.Global + return ((IGuildChannel) Mock).ModifyAsync(func, options); } public OverwritePermissions? GetPermissionOverwrite(IRole role) @@ -305,11 +299,13 @@ Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode , RequestO IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode , RequestOptions options ) { + // ReSharper disable once SuspiciousTypeConversion.Global return ((IChannel)Mock).GetUsersAsync(mode, options); } Task IChannel.GetUserAsync(ulong id, CacheMode mode , RequestOptions options ) { + // ReSharper disable once SuspiciousTypeConversion.Global return ((IChannel)Mock).GetUserAsync(id, mode, options); } diff --git a/InstarBot.Tests.Orchestrator/Models/TestGuild.cs b/InstarBot.Tests.Orchestrator/Models/TestGuild.cs index 54edafd..57202e2 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestGuild.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestGuild.cs @@ -20,9 +20,9 @@ public IEnumerable TextChannels public List Users { get; init; } = []; - public virtual ITextChannel GetTextChannel(ulong channelId) + public virtual ITextChannel? GetTextChannel(ulong channelId) { - return TextChannels.First(n => n.Id.Equals(channelId)); + return TextChannels.FirstOrDefault(n => n.Id.Equals(channelId)); } public virtual IRole? GetRole(Snowflake roleId) diff --git a/InstarBot.Tests.Orchestrator/Models/TestGuildUser.cs b/InstarBot.Tests.Orchestrator/Models/TestGuildUser.cs index 6553d12..4774b75 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestGuildUser.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestGuildUser.cs @@ -1,6 +1,5 @@ using System.Diagnostics.CodeAnalysis; using Discord; -using InstarBot.Tests; using Moq; using PaxAndromeda.Instar; @@ -12,9 +11,9 @@ namespace InstarBot.Test.Framework.Models; [SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] public class TestGuildUser : TestUser, IMockOf, IGuildUser { - public Mock Mock { get; } = new(); + public new Mock Mock { get; } = new(); - private HashSet _roleIds = [ ]; + private HashSet _roleIds; public TestGuildUser() : this(Snowflake.Generate(), [ ]) { } @@ -134,19 +133,21 @@ public Task RemoveTimeOutAsync(RequestOptions options = null) } public DateTimeOffset? JoinedAt { get; init; } - public string DisplayName { get; set; } = null!; - public string Nickname { get; set; } = null!; + public string DisplayName => Nickname ?? Username; + // ReSharper disable once PropertyCanBeMadeInitOnly.Global + public string? Nickname { get; set; } public string DisplayAvatarId { get; set; } = null!; public string GuildAvatarId { get; set; } = null!; public GuildPermissions GuildPermissions { get; set; } public IGuild Guild { get; set; } = null!; - public ulong GuildId { get; internal set; } = 0; + public ulong GuildId { get; internal set; } public DateTimeOffset? PremiumSince { get; set; } public IReadOnlyCollection RoleIds { get => _roleIds.AsReadOnly(); - set => _roleIds = new HashSet(value); + // ReSharper disable once PropertyCanBeMadeInitOnly.Global + set => _roleIds = [..value]; } public bool? IsPending { get; set; } diff --git a/InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs b/InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs index 1481f5a..ad90861 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs @@ -1,26 +1,21 @@ using Discord; -using FluentAssertions; +using JetBrains.Annotations; using Moq; using PaxAndromeda.Instar; -using System; -using System.Threading; using PaxAndromeda.Instar.Services; namespace InstarBot.Test.Framework.Models; public class TestInteractionContext : InstarContext, IMockOf { - private readonly TestOrchestrator _orchestrator; public Mock Mock { get; } = new(); - public IDiscordClient Client => Mock.Object.Client; + public new IDiscordClient Client => Mock.Object.Client; public TestInteractionContext(TestOrchestrator orchestrator, Snowflake actorId, Snowflake channelId) { - _orchestrator = orchestrator; - var discordService = orchestrator.GetService(); - if (discordService.GetUser(actorId) is not { } actor) + if (discordService.GetUser(actorId) is null) throw new InvalidOperationException("Actor needs to be registered before creating an interaction context"); @@ -35,37 +30,8 @@ public TestInteractionContext(TestOrchestrator orchestrator, Snowflake actorId, Mock.SetupGet(static n => n.Guild).Returns(orchestrator.GetService().GetGuild()); } - private Mock SetupGuildMock() - { - var discordService = _orchestrator.GetService(); - - var guildMock = new Mock(); - guildMock.Setup(n => n.Id).Returns(_orchestrator.GuildID); - guildMock.Setup(n => n.GetTextChannel(It.IsAny())) - .Returns((ulong x) => discordService.GetChannel(x) as ITextChannel); - - return guildMock; - } - - private TestGuildUser SetupUser(IGuildUser user) - { - return new TestGuildUser(user.Id, user.RoleIds.Select(id => new Snowflake(id))); - } - - private TestChannel SetupChannel(Snowflake channelId) - { - var discordService = _orchestrator.GetService(); - var channel = discordService.GetChannel(channelId).Result; - - if (channel is not TestChannel testChannel) - throw new InvalidOperationException("Channel must be registered before use in an interaction context"); - - return testChannel; - } - - protected internal override IInstarGuild Guild => Mock.Object.Guild; - protected internal override IGuildChannel Channel => Mock.Object.Channel; - protected internal override IGuildUser User => Mock.Object.User; - public IDiscordInteraction Interaction { get; } = new Mock().Object; + protected internal override IGuildChannel? Channel => Mock.Object.Channel; + protected internal override IGuildUser? User => Mock.Object.User; + [UsedImplicitly] public new IDiscordInteraction Interaction { get; } = new Mock().Object; } \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Models/TestMessage.cs b/InstarBot.Tests.Orchestrator/Models/TestMessage.cs index 71c82ed..aedeff2 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestMessage.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestMessage.cs @@ -1,11 +1,13 @@ using Discord; +using JetBrains.Annotations; using Moq; using PaxAndromeda.Instar; using MessageProperties = Discord.MessageProperties; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. namespace InstarBot.Test.Framework.Models; -public sealed class TestMessage : IMockOf, IUserMessage, IMessage +public sealed class TestMessage : IMockOf, IUserMessage { public Mock Mock { get; } = new(); @@ -19,6 +21,7 @@ internal TestMessage(IUser user, string message) Content = message; } + // ReSharper disable UnusedParameter.Local public TestMessage(string text, bool isTTS, Embed? embed, RequestOptions? options, AllowedMentions? allowedMentions, MessageReference? messageReference, MessageComponent? components, ISticker[]? stickers, Embed[]? embeds, MessageFlags? flags, PollProperties? poll) { Id = Snowflake.Generate(); @@ -27,6 +30,8 @@ public TestMessage(string text, bool isTTS, Embed? embed, RequestOptions? option Flags = flags; var embedList = new List(); + if (embedList == null) + throw new ArgumentNullException(nameof(embedList)); if (embed is not null) embedList.Add(embed); @@ -73,7 +78,7 @@ public IAsyncEnumerable> GetReactionUsersAsync(IEmote public MessageType Type => default; public MessageSource Source => default; - public bool IsTTS { get; set; } + public bool IsTTS { get; } public bool IsPinned => false; public bool IsSuppressed => false; @@ -82,7 +87,7 @@ public IAsyncEnumerable> GetReactionUsersAsync(IEmote public string CleanContent => null!; public DateTimeOffset Timestamp { get; } public DateTimeOffset? EditedTimestamp => null; - public IMessageChannel Channel { get; set; } = null!; + public IMessageChannel Channel { get; init; } = null!; public IUser Author { get; } public IThreadChannel Thread => null!; public IReadOnlyCollection Attachments => null!; @@ -93,12 +98,12 @@ public IAsyncEnumerable> GetReactionUsersAsync(IEmote public IReadOnlyCollection MentionedUserIds => null!; public MessageActivity Activity => null!; public MessageApplication Application => null!; - public MessageReference Reference { get; set; } + public MessageReference? Reference { get; } public IReadOnlyDictionary Reactions => null!; public IReadOnlyCollection Components => null!; public IReadOnlyCollection Stickers => null!; - public MessageFlags? Flags { get; set; } + public MessageFlags? Flags { get; } public IMessageInteraction Interaction => null!; public MessageRoleSubscriptionData RoleSubscriptionData => null!; @@ -143,11 +148,11 @@ public IAsyncEnumerable> GetPollAnswerVotersAsync(uin return Mock.Object.GetPollAnswerVotersAsync(answerId, limit, afterId, options); } - public MessageResolvedData ResolvedData { get; set; } - public IUserMessage ReferencedMessage { get; set; } - public IMessageInteractionMetadata InteractionMetadata { get; set; } - public IReadOnlyCollection ForwardedMessages { get; set; } - public Poll? Poll { get; set; } + public MessageResolvedData ResolvedData { get; [UsedImplicitly] set; } + public IUserMessage ReferencedMessage { get; [UsedImplicitly] set; } + public IMessageInteractionMetadata InteractionMetadata { get; [UsedImplicitly] set; } + public IReadOnlyCollection ForwardedMessages { get; [UsedImplicitly] set; } + public Poll? Poll { get; [UsedImplicitly] set; } public Task DeleteAsync(RequestOptions? options = null) { return Mock.Object.DeleteAsync(options); diff --git a/InstarBot.Tests.Orchestrator/Models/TestRole.cs b/InstarBot.Tests.Orchestrator/Models/TestRole.cs index e9f8eed..884da80 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestRole.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestRole.cs @@ -39,7 +39,7 @@ public int CompareTo(IRole? other) public bool IsHoisted { get; set; } = false; public bool IsManaged { get; set; } = false; public bool IsMentionable { get; set; } = false; - public string Name { get; set; } = null!; + public string Name { get; init; } = null!; public string Icon { get; set; } = null!; public Emoji Emoji { get; set; } = null!; public GuildPermissions Permissions { get; set; } = default!; diff --git a/InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs b/InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs index 377aa2f..95852e7 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs @@ -1,9 +1,11 @@ using Discord; using Discord.WebSocket; +using JetBrains.Annotations; using Moq; namespace InstarBot.Test.Framework.Models; +[UsedImplicitly] public class TestSocketUser : IMockOf { public Mock Mock { get; } = new(); diff --git a/InstarBot.Tests.Orchestrator/Models/TestUser.cs b/InstarBot.Tests.Orchestrator/Models/TestUser.cs index 3678d2f..9e69e83 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestUser.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestUser.cs @@ -2,6 +2,7 @@ using Discord; using Moq; using PaxAndromeda.Instar; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. namespace InstarBot.Test.Framework.Models; @@ -63,7 +64,7 @@ public string GetDisplayAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort return Mock.Object.GetDisplayAvatarUrl(format, size); } - public Task CreateDMChannelAsync(RequestOptions options = null) + public Task CreateDMChannelAsync(RequestOptions? options = null) { return Task.FromResult(DMChannelMock.Object); } @@ -80,7 +81,7 @@ public string GetAvatarDecorationUrl() public bool IsWebhook { get; set; } public string Username { get; set; } public UserProperties? PublicFlags { get; set; } - public string GlobalName { get; set; } + public string GlobalName { get; init; } public string AvatarDecorationHash { get; set; } public ulong? AvatarDecorationSkuId { get; set; } public PrimaryGuild? PrimaryGuild { get; set; } diff --git a/InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs b/InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs index d08c8b5..eca1a7f 100644 --- a/InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs +++ b/InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs @@ -1,4 +1,5 @@ using Discord; +using JetBrains.Annotations; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.ConfigModels; @@ -6,6 +7,7 @@ namespace InstarBot.Test.Framework.Services; +[UsedImplicitly] public class TestAutoMemberSystem : IMockOf, IAutoMemberSystem { public Mock Mock { get; } = new(); diff --git a/InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs b/InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs index 4763b93..3e65d2d 100644 --- a/InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs +++ b/InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs @@ -1,9 +1,11 @@ using Discord; +using JetBrains.Annotations; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Services; namespace InstarBot.Test.Framework.Services; +[UsedImplicitly] public class TestBirthdaySystem : IBirthdaySystem { public Task Start() diff --git a/InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs b/InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs index 63fd6b2..c71d486 100644 --- a/InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs +++ b/InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs @@ -1,57 +1,61 @@ -using System.Diagnostics.CodeAnalysis; -using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DataModel; using Discord; +using JetBrains.Annotations; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; +using System.Diagnostics.CodeAnalysis; namespace InstarBot.Test.Framework.Services; +[UsedImplicitly] public class TestDatabaseService : IMockOf, IDatabaseService { + private readonly IDynamicConfigService _dynamicConfig; private readonly TimeProvider _timeProvider; private readonly Dictionary _userDataTable; private readonly Dictionary _notifications; - private readonly Mock _contextMock; // Although we don't mock anything here, we use this to // throw exceptions if they're configured. public Mock Mock { get; } = new(); - public TestDatabaseService(TimeProvider timeProvider) + public Mock ContextMock { get; } = new(); + + public TestDatabaseService(IDynamicConfigService dynamicConfig, TimeProvider timeProvider) { + _dynamicConfig = dynamicConfig; _timeProvider = timeProvider; _userDataTable = new Dictionary(); _notifications = []; - _contextMock = new Mock(); SetupContextMock(_userDataTable, data => data.UserID!); SetupContextMock(_notifications, notif => notif.Date); } - private void SetupContextMock(Dictionary mapPointer, Func keySelector) + private void SetupContextMock(Dictionary mapPointer, Func keySelector) where V : notnull { - _contextMock.Setup(n => n.DeleteAsync(It.IsAny())).Callback((T data, CancellationToken _) => + ContextMock.Setup(n => n.DeleteAsync(It.IsAny())).Callback((T data, CancellationToken _) => { var key = keySelector(data); mapPointer.Remove(key); }); - _contextMock.Setup(n => n.SaveAsync(It.IsAny())).Callback((T data, CancellationToken _) => + ContextMock.Setup(n => n.SaveAsync(It.IsAny())).Callback((T data, CancellationToken _) => { var key = keySelector(data); mapPointer[key] = data; }); } - public Task?> GetUserAsync(Snowflake snowflake) + public async Task?> GetUserAsync(Snowflake snowflake) { - Mock.Object.GetUserAsync(snowflake); + await Mock.Object.GetUserAsync(snowflake); return !_userDataTable.TryGetValue(snowflake, out var userData) - ? Task.FromResult>(null) - : Task.FromResult(new InstarDatabaseEntry(_contextMock.Object, userData)); + ? null + : new InstarDatabaseEntry(ContextMock.Object, userData); } public Task> GetOrCreateUserAsync(IGuildUser user) @@ -64,7 +68,7 @@ public Task> GetOrCreateUserAsync(IGuildUser _userDataTable[user.Id] = userData; } - return Task.FromResult(new InstarDatabaseEntry(_contextMock.Object, userData)); + return Task.FromResult(new InstarDatabaseEntry(ContextMock.Object, userData)); } [SuppressMessage("ReSharper", "PossibleMultipleEnumeration", Justification = "Doesn't actually enumerate multiple times. First 'enumeration' is a mock which does nothing.")] @@ -79,7 +83,7 @@ public Task>> GetBatchUsersAsync(IEnume returnList.Add(userData); } - return Task.FromResult(returnList.Select(data => new InstarDatabaseEntry(_contextMock.Object, data)).ToList()); + return Task.FromResult(returnList.Select(data => new InstarDatabaseEntry(ContextMock.Object, data)).ToList()); } public Task CreateUserAsync(InstarUserData data) @@ -105,20 +109,22 @@ public Task>> GetUsersByBirthday(DateTi return userBirthdateThisYear >= startUtc && userBirthdateThisYear <= endUtc; }).ToList(); - return Task.FromResult(matchedUsers.Select(data => new InstarDatabaseEntry(_contextMock.Object, data)).ToList()); + return Task.FromResult(matchedUsers.Select(data => new InstarDatabaseEntry(ContextMock.Object, data)).ToList()); } - public Task>> GetPendingNotifications() + public async Task>> GetPendingNotifications() { - Mock.Object.GetPendingNotifications(); + await Mock.Object.GetPendingNotifications(); var currentTimeUtc = _timeProvider.GetUtcNow(); + var cfg = await _dynamicConfig.GetConfig(); + var pendingNotifications = _notifications.Values - .Where(notification => notification.Date <= currentTimeUtc) + .Where(notification => notification.Date <= currentTimeUtc && notification.GuildID == cfg.TargetGuild) .ToList(); - return Task.FromResult(pendingNotifications.Select(data => new InstarDatabaseEntry(_contextMock.Object, data)).ToList()); + return pendingNotifications.Select(data => new InstarDatabaseEntry(ContextMock.Object, data)).ToList(); } public Task> CreateNotificationAsync(Notification notification) @@ -126,6 +132,26 @@ public Task> CreateNotificationAsync(Notificat Mock.Object.CreateNotificationAsync(notification); _notifications[notification.Date] = notification; - return Task.FromResult(new InstarDatabaseEntry(_contextMock.Object, notification)); + return Task.FromResult(new InstarDatabaseEntry(ContextMock.Object, notification)); + } + + public Task>> GetNotificationsByTypeAndReferenceUser(NotificationType type, Snowflake userId) + { + return Task.FromResult( + _notifications.Values + .Where(n => n.Type == type && n.ReferenceUser == userId) + .Select(n => new InstarDatabaseEntry(ContextMock.Object, n)) + .ToList() + ); + } + + public List GetAllNotifications() + { + return _notifications.Values.ToList(); + } + + public void DeleteUser(Snowflake userId) + { + _userDataTable.Remove(userId); } } \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs b/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs index 29d13e6..0ecee3e 100644 --- a/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs +++ b/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs @@ -4,7 +4,6 @@ using PaxAndromeda.Instar; using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Services; -using System.Threading.Channels; namespace InstarBot.Test.Framework.Services; @@ -75,9 +74,9 @@ public Task> GetAllUsers() return Task.FromResult(_guild.Users.AsEnumerable()); } - public Task GetChannel(Snowflake channelId) + public Task GetChannel(Snowflake channelId) { - return Task.FromResult(_guild.GetTextChannel(channelId)); + return Task.FromResult(_guild.GetTextChannel(channelId)); } public IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime) diff --git a/InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs b/InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs index d45d3f3..7b57445 100644 --- a/InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs +++ b/InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs @@ -1,15 +1,19 @@ using InstarBot.Test.Framework.Models; +using JetBrains.Annotations; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Gaius; using PaxAndromeda.Instar.Services; namespace InstarBot.Test.Framework.Services; -public sealed class TestGaiusAPIService : IGaiusAPIService +[UsedImplicitly] +public sealed class TestGaiusAPIService ( + Dictionary> warnings, + Dictionary> caselogs, + bool inhibit = false) + : IGaiusAPIService { - private readonly Dictionary> _warnings; - private readonly Dictionary> _caselogs; - private bool _inhibit; + private bool _inhibit = inhibit; public void Inhibit() { @@ -18,18 +22,18 @@ public void Inhibit() public void AddWarning(TestGuildUser user, Warning warning) { - if (!_warnings.ContainsKey(user.Id)) - _warnings[user.Id] = []; + if (!warnings.ContainsKey(user.Id)) + warnings[user.Id] = []; - _warnings[user.Id].Add(warning); + warnings[user.Id].Add(warning); } public void AddCaselog(TestGuildUser user, Caselog caselog) { - if (!_caselogs.ContainsKey(user.Id)) - _caselogs[user.Id] = [ ]; + if (!caselogs.ContainsKey(user.Id)) + caselogs[user.Id] = [ ]; - _caselogs[user.Id].Add(caselog); + caselogs[user.Id].Add(caselog); } public TestGaiusAPIService() : @@ -39,15 +43,6 @@ public TestGaiusAPIService(bool inhibit) : this(new Dictionary>(), new Dictionary>(), inhibit) { } - public TestGaiusAPIService(Dictionary> warnings, - Dictionary> caselogs, - bool inhibit = false) - { - _warnings = warnings; - _caselogs = caselogs; - _inhibit = inhibit; - } - public void Dispose() { // do nothing @@ -55,22 +50,22 @@ public void Dispose() public Task> GetAllWarnings() { - return Task.FromResult>(_warnings.Values.SelectMany(list => list).ToList()); + return Task.FromResult>(warnings.Values.SelectMany(list => list).ToList()); } public Task> GetAllCaselogs() { - return Task.FromResult>(_caselogs.Values.SelectMany(list => list).ToList()); + return Task.FromResult>(caselogs.Values.SelectMany(list => list).ToList()); } public Task> GetWarningsAfter(DateTime dt) { - return Task.FromResult>(from list in _warnings.Values from item in list where item.WarnDate > dt select item); + return Task.FromResult>(from list in warnings.Values from item in list where item.WarnDate > dt select item); } public Task> GetCaselogsAfter(DateTime dt) { - return Task.FromResult>(from list in _caselogs.Values from item in list where item.Date > dt select item); + return Task.FromResult>(from list in caselogs.Values from item in list where item.Date > dt select item); } public Task?> GetWarnings(Snowflake userId) @@ -78,7 +73,7 @@ public Task> GetCaselogsAfter(DateTime dt) if (_inhibit) return Task.FromResult?>(null); - return !_warnings.TryGetValue(userId, out var warning) + return !warnings.TryGetValue(userId, out var warning) ? Task.FromResult?>([]) : Task.FromResult?>(warning); } @@ -88,7 +83,7 @@ public Task> GetCaselogsAfter(DateTime dt) if (_inhibit) return Task.FromResult?>(null); - return !_caselogs.TryGetValue(userId, out var caselog) + return !caselogs.TryGetValue(userId, out var caselog) ? Task.FromResult?>([]) : Task.FromResult?>(caselog); } diff --git a/InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs b/InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs deleted file mode 100644 index 7f87c4b..0000000 --- a/InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Discord; -using InstarBot.Test.Framework.Models; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.DynamoModels; - -namespace InstarBot.Test.Framework; - -public class TestDatabaseContext -{ -} - -public class TestDatabaseContextBuilder -{ - private readonly TestDiscordContextBuilder? _discordContextBuilder; - - private Dictionary _registeredUsers = new(); - - public TestDatabaseContextBuilder(ref TestDiscordContextBuilder? discordContextBuilder) - { - _discordContextBuilder = discordContextBuilder; - } - - public TestDatabaseContextBuilder RegisterUser(Snowflake userId) - => RegisterUser(userId, x => x); - - public TestDatabaseContextBuilder RegisterUser(Snowflake userId, Func editExpr) - { - if (_registeredUsers.TryGetValue(userId, out InstarUserData? userData)) - { - _registeredUsers[userId] = editExpr(userData); - return this; - } - - if (_discordContextBuilder is null || !_discordContextBuilder.TryGetUser(userId, out TestGuildUser guildUser)) - throw new InvalidOperationException($"You must register {userId.ID} as a Discord user before calling this method."); - - _registeredUsers[userId] = editExpr(InstarUserData.CreateFrom(guildUser)); - - return this; - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs b/InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs index 934370a..3d3a2f6 100644 --- a/InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs +++ b/InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs @@ -1,26 +1,22 @@ -using System.Collections.ObjectModel; -using Discord; -using InstarBot.Test.Framework.Models; +using InstarBot.Test.Framework.Models; +using JetBrains.Annotations; using PaxAndromeda.Instar; using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.Services; +using System.Collections.ObjectModel; namespace InstarBot.Test.Framework; -public class TestDiscordContext +public class TestDiscordContext( + Snowflake guildId, + IEnumerable users, + IEnumerable channels, + IEnumerable roles) { - public Snowflake GuildId { get; } - public ReadOnlyCollection Users { get; } - public ReadOnlyCollection Channels { get; } - public ReadOnlyCollection Roles { get; } - - public TestDiscordContext(Snowflake guildId, IEnumerable users, IEnumerable channels, IEnumerable roles) - { - GuildId = guildId; - Users = users.ToList().AsReadOnly(); - Channels = channels.ToList().AsReadOnly(); - Roles = roles.ToList().AsReadOnly(); - } + public Snowflake GuildId { get; } = guildId; + public ReadOnlyCollection Users { get; } = users.ToList().AsReadOnly(); + public ReadOnlyCollection Channels { get; } = channels.ToList().AsReadOnly(); + public ReadOnlyCollection Roles { get; } = roles.ToList().AsReadOnly(); public static TestDiscordContextBuilder Builder => new(); } @@ -37,6 +33,7 @@ public TestDiscordContext Build() return new TestDiscordContext(_guildId, _registeredUsers.Values, _registeredChannels.Values, _registeredRoles.Values); } + [UsedImplicitly] public async Task LoadFromConfig(IDynamicConfigService configService) { var cfg = await configService.GetConfig(); @@ -115,7 +112,6 @@ private void RegisterUser(Snowflake snowflake, string name = "User") _registeredUsers.Add(snowflake, new TestGuildUser(snowflake) { GlobalName = name, - DisplayName = name, Username = name, Nickname = name }); @@ -127,7 +123,7 @@ public TestDiscordContextBuilder RegisterUser(TestGuildUser user) return this; } - internal bool TryGetUser(Snowflake userId, out TestGuildUser testGuildUser) + internal bool TryGetUser(Snowflake userId, out TestGuildUser? testGuildUser) { return _registeredUsers.TryGetValue(userId, out testGuildUser); } diff --git a/InstarBot.Tests.Orchestrator/TestOrchestrator.cs b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs index 448eee3..5db5933 100644 --- a/InstarBot.Tests.Orchestrator/TestOrchestrator.cs +++ b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs @@ -11,10 +11,7 @@ using Serilog; using Serilog.Events; using System.Diagnostics.CodeAnalysis; -using System.Reactive.Subjects; using System.Runtime.InteropServices; -using static Microsoft.ApplicationInsights.MetricDimensionNames.TelemetryContext; -using BindingFlags = System.Reflection.BindingFlags; using InvalidOperationException = Amazon.CloudWatchLogs.Model.InvalidOperationException; namespace InstarBot.Test.Framework @@ -44,7 +41,7 @@ public IGuildUser Actor { } } - public TestGuildUser Subject { get; set; } + public TestGuildUser Subject { get; set; } = null!; public Snowflake GuildID => GetService().GetGuild().Id; @@ -135,8 +132,6 @@ public T GetService() where T : class { public Mock GetCommand(Func constructor) where T : BaseCommand { - var constructors = typeof(T).GetConstructors(); - var mock = new Mock(() => constructor()); var executionChannel = Snowflake.Generate(); diff --git a/InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs b/InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs index 34e6646..ec18337 100644 --- a/InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs +++ b/InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs @@ -1,12 +1,10 @@ -using Discord; -using InstarBot.Test.Framework.Models; +using InstarBot.Test.Framework.Models; using InstarBot.Test.Framework.Services; +using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using Moq; using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.Services; -using InvalidOperationException = Amazon.CloudWatchLogs.Model.InvalidOperationException; namespace InstarBot.Test.Framework; @@ -16,15 +14,13 @@ public class TestServiceProviderBuilder private readonly Dictionary _serviceRegistry = new(); private readonly Dictionary _serviceTypeRegistry = new(); - private readonly HashSet _componentRegistry = new(); private string _configPath = DefaultConfigPath; - private TestDiscordContextBuilder? _discordContextBuilder = null; - private TestDatabaseContextBuilder? _databaseContextBuilder = null; - private readonly Dictionary _interactionCallerIds = new(); + private TestDiscordContextBuilder? _discordContextBuilder; private Snowflake _actor = Snowflake.Generate(); private TestGuildUser _subject = new(Snowflake.Generate()); + [UsedImplicitly] public TestServiceProviderBuilder WithConfigPath(string configPath) { _configPath = configPath; @@ -59,13 +55,6 @@ public TestServiceProviderBuilder WithDiscordContext(Func builderExpr) - { - _databaseContextBuilder ??= new TestDatabaseContextBuilder(ref _discordContextBuilder); - _databaseContextBuilder = builderExpr(_databaseContextBuilder); - return this; - } - public TestServiceProviderBuilder WithActor(Snowflake userId) { _actor = userId; @@ -85,8 +74,6 @@ public async Task Build() services.AddSingleton(type, implementation); foreach (var (iType, implType) in _serviceTypeRegistry) services.AddSingleton(iType, implType); - foreach (var type in _componentRegistry) - services.AddTransient(type); IDynamicConfigService configService; if (_serviceRegistry.TryGetValue(typeof(IDynamicConfigService), out var registeredService) && @@ -107,6 +94,7 @@ public async Task Build() RegisterDefaultService(services); RegisterDefaultService(services); RegisterDefaultService(services); + RegisterDefaultService(services); RegisterDefaultService(services); return new TestOrchestrator(services.BuildServiceProvider(), _actor, _subject); diff --git a/InstarBot.Tests.Orchestrator/TestTimeProvider.cs b/InstarBot.Tests.Orchestrator/TestTimeProvider.cs index 45cf7f9..271c56c 100644 --- a/InstarBot.Tests.Orchestrator/TestTimeProvider.cs +++ b/InstarBot.Tests.Orchestrator/TestTimeProvider.cs @@ -23,6 +23,6 @@ public void SetTime(DateTimeOffset time) public override DateTimeOffset GetUtcNow() { - return _time is null ? DateTimeOffset.UtcNow : ((DateTimeOffset) _time).ToUniversalTime(); + return _time?.ToUniversalTime() ?? DateTimeOffset.UtcNow; } } \ No newline at end of file diff --git a/InstarBot.Tests.Unit/DynamoModels/EventListTests.cs b/InstarBot.Tests.Unit/DynamoModels/EventListTests.cs index f041a8c..0ac551d 100644 --- a/InstarBot.Tests.Unit/DynamoModels/EventListTests.cs +++ b/InstarBot.Tests.Unit/DynamoModels/EventListTests.cs @@ -68,7 +68,7 @@ public void Add_LastItem_ShouldBeAddedInMiddle() new(2, DateTime.Now - TimeSpan.FromMinutes(1)) }; - list.Latest().Value.Should().Be(3); + list.Latest()?.Value.Should().Be(3); list.First().Value.Should().Be(2); // Act @@ -117,7 +117,7 @@ public void Add_RandomItems_ShouldBeChronological() private class TestEntry(T value, DateTime date) : ITimedEvent { - public DateTime Date { get; set; } = date; - public T Value { get; set; } = value; + public DateTime Date { get; } = date; + public T Value { get; } = value; } } \ No newline at end of file diff --git a/InstarBot/Caching/MemoryCache.cs b/InstarBot/Caching/MemoryCache.cs index f61b53a..94c6ab5 100644 --- a/InstarBot/Caching/MemoryCache.cs +++ b/InstarBot/Caching/MemoryCache.cs @@ -1,3 +1,4 @@ +using JetBrains.Annotations; using System.Collections.Specialized; using System.Diagnostics.CodeAnalysis; using System.Runtime.Caching; @@ -15,17 +16,20 @@ public MemoryCache(string name, NameValueCollection config, bool ignoreConfigSec { } - public bool Add(string key, T value, DateTimeOffset absoluteExpiration, string regionName = null!) + [UsedImplicitly] + public bool Add(string key, T value, DateTimeOffset absoluteExpiration, string regionName = null!) { return value != null && base.Add(key, value, absoluteExpiration, regionName); } - public bool Add(string key, T value, CacheItemPolicy policy, string regionName = null!) + [UsedImplicitly] + public bool Add(string key, T value, CacheItemPolicy policy, string regionName = null!) { return value != null && base.Add(key, value, policy, regionName); } - public new T Get(string key, string regionName = null!) + [UsedImplicitly] + public new T Get(string key, string regionName = null!) { return (T) base.Get(key, regionName) ?? throw new InvalidOperationException($"{nameof(key)} cannot be null"); } diff --git a/InstarBot/Commands/AutoMemberHoldCommand.cs b/InstarBot/Commands/AutoMemberHoldCommand.cs index 5f1d44a..5dad59c 100644 --- a/InstarBot/Commands/AutoMemberHoldCommand.cs +++ b/InstarBot/Commands/AutoMemberHoldCommand.cs @@ -11,7 +11,7 @@ namespace PaxAndromeda.Instar.Commands; [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class AutoMemberHoldCommand(IDatabaseService ddbService, IDynamicConfigService dynamicConfigService, IMetricService metricService, TimeProvider timeProvider) : BaseCommand +public class AutoMemberHoldCommand(IDatabaseService ddbService, IDynamicConfigService dynamicConfigService, IMetricService metricService, INotificationService notificationService, TimeProvider timeProvider) : BaseCommand { [UsedImplicitly] [SlashCommand("amh", "Withhold automatic membership grants to a user.")] @@ -62,6 +62,30 @@ string reason }; await dbUser.CommitAsync(); + // Create a notification for the future + await notificationService.QueueNotification(new Notification + { + Actor = modId, + Channel = config.StaffAnnounceChannel, + Type = NotificationType.AutoMemberHold, + Priority = NotificationPriority.Normal, + Subject = "Auto Member Hold Reminder", + Targets = [ + new NotificationTarget { Id = config.StaffRoleID, Type = NotificationTargetType.Role }, + new NotificationTarget { Id = modId, Type = NotificationTargetType.User } + ], + Data = new NotificationData + { + Message = Strings.Command_AutoMemberHold_NotificationMessage, + Fields = [ + new NotificationEmbedField { Name = "**User**", Value = $"<@{user.Id}>\r\n`{user.Id}`", Inline = true }, + new NotificationEmbedField { Name = "**Issuer**", Value = $"<@{modId.ID}>\r\n`{modId.ID}`", Inline = true }, + new NotificationEmbedField { Name = "**Reason**", Value = $"```{reason}```" } + ] + }, + ReferenceUser = user.Id + }, TimeSpan.FromDays(7)); + // TODO: configurable duration? await RespondAsync(string.Format(Strings.Command_AutoMemberHold_Success, user.Id), ephemeral: true); } catch (Exception ex) @@ -108,6 +132,9 @@ IUser user dbUser.Data.AutoMemberHoldRecord = null; await dbUser.CommitAsync(); + // Purge any pending notifications asynchronously. + _ = Task.Factory.StartNew(() => PurgeNotification(user.Id)); + await RespondAsync(string.Format(Strings.Command_AutoMemberUnhold_Success, user.Id), ephemeral: true); } catch (Exception ex) @@ -125,4 +152,21 @@ IUser user } } } + + private async Task PurgeNotification(Snowflake userId) + { + try + { + var results = await ddbService.GetNotificationsByTypeAndReferenceUser(NotificationType.AutoMemberHold, userId); + + foreach (var result in results) + { + Log.Debug("Deleting AMH notification for {UserID} dated {Date}", userId.ID, result.Data.Date); + await result.DeleteAsync(); + } + } catch (Exception ex) + { + Log.Error(ex, "Failed to remove AMH notification for user {UserID}", userId.ID); + } + } } \ No newline at end of file diff --git a/InstarBot/Commands/ReportUserCommand.cs b/InstarBot/Commands/ReportUserCommand.cs index 745f433..e0e7b17 100644 --- a/InstarBot/Commands/ReportUserCommand.cs +++ b/InstarBot/Commands/ReportUserCommand.cs @@ -66,22 +66,28 @@ public async Task ModalResponse(ReportMessageModal modal) await RespondAsync(Strings.Command_ReportUser_ReportSent, ephemeral: true); } - private async Task SendReportMessage(ReportMessageModal modal, IMessage message, IInstarGuild guild) - { + private async Task SendReportMessage(ReportMessageModal modal, IMessage message, IInstarGuild guild) + { Guard.Against.Null(Context.User); - var cfg = await dynamicConfig.GetConfig(); - + var cfg = await dynamicConfig.GetConfig(); + #if DEBUG - const string staffPing = "{{staffping}}"; + const string staffPing = "{{staffping}}"; #else var staffPing = Snowflake.GetMention(() => cfg.StaffRoleID); #endif - await - Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel) - .SendMessageAsync(staffPing, embed: new InstarReportUserEmbed(modal, Context.User, message, guild).Build()); + var announceChannel = Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel); - await metricService.Emit(Metric.ReportUser_ReportsSent, 1); - } + if (announceChannel is null) + { + Log.Error("Could not find staff announce channel by ID {ChannelID}", cfg.StaffAnnounceChannel.ID); + return; + } + + await announceChannel.SendMessageAsync(staffPing, embed: new InstarReportUserEmbed(modal, Context.User, message, guild).Build()); + + await metricService.Emit(Metric.ReportUser_ReportsSent, 1); + } } \ No newline at end of file diff --git a/InstarBot/Commands/SetBirthdayCommand.cs b/InstarBot/Commands/SetBirthdayCommand.cs index 309effe..3d9de5d 100644 --- a/InstarBot/Commands/SetBirthdayCommand.cs +++ b/InstarBot/Commands/SetBirthdayCommand.cs @@ -130,7 +130,6 @@ await RespondAsync( birthday.Birthdate, birthday.Birthdate.UtcDateTime); // Fourth step: Grant birthday role if the user's birthday is today THEIR time. - // TODO: Ensure that a user is granted/removed birthday roles appropriately after setting their birthday IF their birthday is today. if (birthday.IsToday) { // User's birthday is today in their timezone; grant birthday role. @@ -154,6 +153,12 @@ private async Task HandleUnderage(InstarDynamicConfiguration cfg, IGuildUser use { var staffAnnounceChannel = Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel); + if (staffAnnounceChannel is null) + { + Log.Error("Could not find staff announce channel by ID {ChannelID}", cfg.StaffAnnounceChannel.ID); + return; + } + var warningEmbed = new InstarUnderageUserWarningEmbed(cfg, user, user.RoleIds.Contains(cfg.MemberRoleID), birthday).Build(); await staffAnnounceChannel.SendMessageAsync($"<@&{cfg.StaffRoleID}>", embed: warningEmbed); diff --git a/InstarBot/ConfigModels/AutoMemberConfig.cs b/InstarBot/ConfigModels/AutoMemberConfig.cs index 262f88f..fa94058 100644 --- a/InstarBot/ConfigModels/AutoMemberConfig.cs +++ b/InstarBot/ConfigModels/AutoMemberConfig.cs @@ -3,15 +3,18 @@ namespace PaxAndromeda.Instar.ConfigModels; +[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] +[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] +[UsedImplicitly] public sealed class AutoMemberConfig { - [SnowflakeType(SnowflakeType.Role)] public Snowflake HoldRole { get; set; } = null!; - [SnowflakeType(SnowflakeType.Channel)] public Snowflake IntroductionChannel { get; set; } = null!; - public int MinimumJoinAge { get; set; } - public int MinimumMessages { get; set; } - public int MinimumMessageTime { get; set; } - public List RequiredRoles { get; set; } = null!; - public bool EnableGaiusCheck { get; set; } + [SnowflakeType(SnowflakeType.Role)] public Snowflake HoldRole { get; init; } = null!; + [SnowflakeType(SnowflakeType.Channel)] public Snowflake IntroductionChannel { get; init; } = null!; + public int MinimumJoinAge { get; init; } + public int MinimumMessages { get; init; } + public int MinimumMessageTime { get; init; } + public List RequiredRoles { get; init; } = null!; + public bool EnableGaiusCheck { get; init; } } [UsedImplicitly] diff --git a/InstarBot/ConfigModels/InstarDynamicConfiguration.cs b/InstarBot/ConfigModels/InstarDynamicConfiguration.cs index 62a35a0..5f1bf07 100644 --- a/InstarBot/ConfigModels/InstarDynamicConfiguration.cs +++ b/InstarBot/ConfigModels/InstarDynamicConfiguration.cs @@ -18,8 +18,9 @@ public sealed class InstarDynamicConfiguration [SnowflakeType(SnowflakeType.Role)] public Snowflake StaffRoleID { get; set; } = null!; [SnowflakeType(SnowflakeType.Role)] public Snowflake NewMemberRoleID { get; set; } = null!; [SnowflakeType(SnowflakeType.Role)] public Snowflake MemberRoleID { get; set; } = null!; - public Snowflake[] AuthorizedStaffID { get; set; } = null!; - public AutoMemberConfig AutoMemberConfig { get; set; } = null!; + public Snowflake[] AuthorizedStaffID { get; set; } = null!; + public Snowflake[]? AutoKickRoles { get; set; } = null!; + public AutoMemberConfig AutoMemberConfig { get; set; } = null!; public BirthdayConfig BirthdayConfig { get; set; } = null!; public Team[] Teams { get; set; } = null!; public Dictionary FunCommands { get; set; } = null!; @@ -32,14 +33,14 @@ public class BirthdayConfig public Snowflake BirthdayRole { get; set; } = null!; [SnowflakeType(SnowflakeType.Channel)] public Snowflake BirthdayAnnounceChannel { get; set; } = null!; - public int MinimumPermissibleAge { get; set; } + public int MinimumPermissibleAge { get; [UsedImplicitly] set; } public List AgeRoleMap { get; set; } = null!; } [UsedImplicitly] public record AgeRoleMapping { - public int Age { get; set; } + public int Age { get; [UsedImplicitly] set; } [SnowflakeType(SnowflakeType.Role)] public Snowflake Role { get; set; } = null!; diff --git a/InstarBot/DynamoModels/EventList.cs b/InstarBot/DynamoModels/EventList.cs index 57f3a05..d5bded9 100644 --- a/InstarBot/DynamoModels/EventList.cs +++ b/InstarBot/DynamoModels/EventList.cs @@ -1,6 +1,7 @@ using System.Collections; using Amazon.DynamoDBv2.DataModel; using Amazon.DynamoDBv2.DocumentModel; +using JetBrains.Annotations; using Newtonsoft.Json; namespace PaxAndromeda.Instar.DynamoModels; @@ -46,6 +47,7 @@ public void Add(T item) IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } +[UsedImplicitly] public class EventListPropertyConverter : IPropertyConverter where T: ITimedEvent { public DynamoDBEntry ToEntry(object value) diff --git a/InstarBot/DynamoModels/InstarUserData.cs b/InstarBot/DynamoModels/InstarUserData.cs index 8a54478..5146607 100644 --- a/InstarBot/DynamoModels/InstarUserData.cs +++ b/InstarBot/DynamoModels/InstarUserData.cs @@ -104,13 +104,13 @@ public static InstarUserData CreateFrom(IGuildUser user) public record AutoMemberHoldRecord { [DynamoDBProperty("date")] - public DateTime Date { get; set; } + public DateTime Date { get; init; } [DynamoDBProperty("mod", Converter = typeof(InstarSnowflakePropertyConverter))] - public Snowflake ModeratorID { get; set; } + public Snowflake ModeratorID { get; init; } [DynamoDBProperty("reason")] - public string Reason { get; set; } + public string Reason { get; init; } } public interface ITimedEvent @@ -122,10 +122,10 @@ public interface ITimedEvent public record InstarUserDataHistoricalEntry : ITimedEvent { [DynamoDBProperty("date")] - public DateTime Date { get; set; } + public DateTime Date { get; [UsedImplicitly] set; } [DynamoDBProperty("data")] - public T? Data { get; set; } + public T? Data { get; [UsedImplicitly] set; } public InstarUserDataHistoricalEntry() { @@ -175,6 +175,7 @@ public record InstarUserDataReports public Snowflake Reporter { get; set; } } +[UsedImplicitly] public record InstarModLogEntry { [DynamoDBProperty("context")] @@ -249,7 +250,7 @@ public DynamoDBEntry ToEntry(object value) return name?.Value ?? "UNKNOWN"; } - public object FromEntry(DynamoDBEntry entry) + public object? FromEntry(DynamoDBEntry entry) { var sEntry = entry.AsString(); if (sEntry is null || string.IsNullOrWhiteSpace(entry.AsString())) diff --git a/InstarBot/DynamoModels/Notification.cs b/InstarBot/DynamoModels/Notification.cs index ac8b7dc..f2dda05 100644 --- a/InstarBot/DynamoModels/Notification.cs +++ b/InstarBot/DynamoModels/Notification.cs @@ -1,7 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.Serialization; using Amazon.DynamoDBv2.DataModel; -using Amazon.DynamoDBv2.DocumentModel; +using JetBrains.Annotations; + +// Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +#pragma warning disable CS8618 namespace PaxAndromeda.Instar.DynamoModels; @@ -10,43 +13,88 @@ namespace PaxAndromeda.Instar.DynamoModels; public class Notification { [DynamoDBHashKey("guild_id", Converter = typeof(InstarSnowflakePropertyConverter))] - public Snowflake? GuildID { get; set; } + [DynamoDBGlobalSecondaryIndexHashKey("gsi_type_referenceuser", AttributeName = "guild_id")] + public Snowflake GuildID { get; set; } [DynamoDBRangeKey("date")] public DateTime Date { get; set; } + [DynamoDBProperty("type", Converter = typeof(InstarEnumPropertyConverter))] + [DynamoDBGlobalSecondaryIndexHashKey("gsi_type_referenceuser", AttributeName = "type")] + public NotificationType Type { get; set; } + [DynamoDBProperty("actor", Converter = typeof(InstarSnowflakePropertyConverter))] - public Snowflake? Actor { get; set; } + public Snowflake Actor { get; set; } [DynamoDBProperty("subject")] public string Subject { get; set; } [DynamoDBProperty("channel", Converter = typeof(InstarSnowflakePropertyConverter))] - public Snowflake? Channel { get; set; } + public Snowflake Channel { get; set; } [DynamoDBProperty("target")] public List Targets { get; set; } [DynamoDBProperty("priority", Converter = typeof(InstarEnumPropertyConverter))] - public NotificationPriority? Priority { get; set; } = NotificationPriority.Normal; + public NotificationPriority Priority { get; set; } = NotificationPriority.Normal; [DynamoDBProperty("data")] public NotificationData Data { get; set; } + + [DynamoDBProperty("send_attempts")] + public int SendAttempts { get; set; } + + [DynamoDBProperty("reference_user", Converter = typeof(InstarSnowflakePropertyConverter))] + [DynamoDBGlobalSecondaryIndexRangeKey("gsi_type_referenceuser", AttributeName = "reference_user")] + public Snowflake? ReferenceUser { get; set; } +} + +public enum NotificationType +{ + [EnumMember(Value = "NORMAL")] + Normal, + + [EnumMember(Value = "AMH")] + AutoMemberHold } public record NotificationTarget { [DynamoDBProperty("type", Converter = typeof(InstarEnumPropertyConverter))] - public NotificationTargetType Type { get; set; } + public NotificationTargetType Type { get; init; } [DynamoDBProperty("id", Converter = typeof(InstarSnowflakePropertyConverter))] - public Snowflake? Id { get; set; } + public Snowflake Id { get; init; } } public record NotificationData { [DynamoDBProperty("message")] - public string? Message { get; set; } + public string Message { get; init; } + + [DynamoDBProperty("url")] + public string? Url { get; [UsedImplicitly] init; } + + [DynamoDBProperty("image_url")] + public string? ImageUrl { get; [UsedImplicitly] init; } + + [DynamoDBProperty("thumbnail_url")] + public string? ThumbnailUrl { get; [UsedImplicitly] init; } + + [DynamoDBProperty("fields")] + public List? Fields { get; init; } +} + +public record NotificationEmbedField +{ + [DynamoDBProperty("name")] + public string Name { get; init; } + + [DynamoDBProperty("value")] + public string Value { get; init; } + + [DynamoDBProperty("inline")] + public bool? Inline { get; init; } } public enum NotificationTargetType diff --git a/InstarBot/Embeds/InstarEmbed.cs b/InstarBot/Embeds/InstarEmbed.cs index a795ffa..bb40248 100644 --- a/InstarBot/Embeds/InstarEmbed.cs +++ b/InstarBot/Embeds/InstarEmbed.cs @@ -1,4 +1,5 @@ using Discord; +using JetBrains.Annotations; namespace PaxAndromeda.Instar.Embeds; @@ -6,5 +7,6 @@ public abstract class InstarEmbed { public const string InstarLogoUrl = "https://spacegirl.s3.us-east-1.amazonaws.com/instar.png"; + [UsedImplicitly] public abstract Embed Build(); } \ No newline at end of file diff --git a/InstarBot/Embeds/NotificationEmbed.cs b/InstarBot/Embeds/NotificationEmbed.cs new file mode 100644 index 0000000..0cee9f7 --- /dev/null +++ b/InstarBot/Embeds/NotificationEmbed.cs @@ -0,0 +1,54 @@ +using Discord; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; + +namespace PaxAndromeda.Instar.Embeds; + +public class NotificationEmbed(Notification notification, IGuildUser? actor, InstarDynamicConfiguration cfg) + : InstarEmbed +{ + public override Embed Build() + { + var fields = new List(); + + if (notification.Data.Fields is not null) + { + fields.AddRange(notification.Data.Fields.Select(data => + { + var builder = new EmbedFieldBuilder() + .WithName(data.Name) + .WithValue(data.Value); + + if (data.Inline is true) + builder = builder.WithIsInline(true); + + return builder; + })); + } + + var builder = new EmbedBuilder() + // Set up all the basic stuff first + .WithTimestamp(notification.Date) + .WithColor(0x0c94e0) + .WithFooter(new EmbedFooterBuilder() + .WithText(Strings.Embed_Notification_Footer) + .WithIconUrl(Strings.InstarLogoUrl)) + // Description + .WithTitle(notification.Subject) + .WithDescription(notification.Data.Message) + .WithFields(fields); + + builder = actor is not null + ? builder.WithAuthor(actor.DisplayName, actor.GetDisplayAvatarUrl()) + : builder.WithAuthor(cfg.BotName, Strings.InstarLogoUrl); + + if (notification.Data.Url is not null) + builder = builder.WithUrl(notification.Data.Url); + if (notification.Data.ImageUrl is not null) + builder = builder.WithImageUrl(notification.Data.ImageUrl); + if (notification.Data.ThumbnailUrl is not null) + builder = builder.WithThumbnailUrl(notification.Data.ThumbnailUrl); + + return builder.Build(); + } +} \ No newline at end of file diff --git a/InstarBot/IBuilder.cs b/InstarBot/IBuilder.cs index ef77248..40d5f7d 100644 --- a/InstarBot/IBuilder.cs +++ b/InstarBot/IBuilder.cs @@ -1,6 +1,9 @@ -namespace PaxAndromeda.Instar; +using JetBrains.Annotations; + +namespace PaxAndromeda.Instar; public interface IBuilder { + [UsedImplicitly] T Build(); } \ No newline at end of file diff --git a/InstarBot/IInstarGuild.cs b/InstarBot/IInstarGuild.cs index 59d1c7a..dd3b3e5 100644 --- a/InstarBot/IInstarGuild.cs +++ b/InstarBot/IInstarGuild.cs @@ -9,6 +9,6 @@ public interface IInstarGuild ulong Id { get; } IEnumerable TextChannels { get; } - ITextChannel GetTextChannel(ulong channelId); - IRole GetRole(Snowflake newMemberRole); + ITextChannel? GetTextChannel(ulong channelId); + IRole? GetRole(Snowflake newMemberRole); } \ No newline at end of file diff --git a/InstarBot/InstarBot.csproj b/InstarBot/InstarBot.csproj index 16d4c96..19d97bf 100644 --- a/InstarBot/InstarBot.csproj +++ b/InstarBot/InstarBot.csproj @@ -27,6 +27,7 @@ + diff --git a/InstarBot/Metrics/Metric.cs b/InstarBot/Metrics/Metric.cs index 2515155..363dfbc 100644 --- a/InstarBot/Metrics/Metric.cs +++ b/InstarBot/Metrics/Metric.cs @@ -5,7 +5,6 @@ namespace PaxAndromeda.Instar.Metrics; [SuppressMessage("ReSharper", "InconsistentNaming")] public enum Metric { - [MetricName("Schedule Deviation")] ScheduledService_ScheduleDeviation, @@ -51,6 +50,10 @@ public enum Metric [MetricDimension("Service", "Auto Member System")] [MetricName("AMH Application Failures")] AMS_AMHFailures, + + [MetricDimension("Service", "Auto Member System")] + [MetricName("Autokicks due to Forbidden Roles")] + AMS_ForbiddenRoleKicks, [MetricDimension("Service", "Birthday System")] [MetricName("Birthday System Failures")] @@ -82,5 +85,25 @@ public enum Metric [MetricDimension("Service", "Gaius")] [MetricName("Gaius API Latency")] - Gaius_APILatency + Gaius_APILatency, + + [MetricDimension("Service", "Notifications")] + [MetricName("Malformed Notifications")] + Notification_MalformedNotification, + + [MetricDimension("Service", "Notifications")] + [MetricName("Notifications Failed")] + Notification_NotificationsFailed, + + [MetricDimension("Service", "Notifications")] + [MetricName("Notifications Sent")] + Notification_NotificationsSent, + + [MetricDimension("Service", "Time")] + [MetricName("NTP Query Errors")] + NTP_Error, + + [MetricDimension("Service", "Time")] + [MetricName("Clock Drift (µs)")] + NTP_Drift, } \ No newline at end of file diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index 5dfd878..db0615d 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Amazon; using Amazon.CloudWatchLogs; +using CommandLine; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using PaxAndromeda.Instar.Commands; @@ -74,7 +75,9 @@ private static async Task RunAsync(IConfiguration config) // Start up other systems List tasks = [ _services.GetRequiredService().Start(), - _services.GetRequiredService().Start() + _services.GetRequiredService().Start(), + _services.GetRequiredService().Start(), + _services.GetRequiredService().Start() ]; Task.WaitAll(tasks); @@ -135,7 +138,9 @@ private static ServiceProvider ConfigureServices(IConfiguration config) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(TimeProvider.System); + services.AddSingleton(); #if DEBUG services.AddSingleton(); diff --git a/InstarBot/Services/AWSDynamicConfigService.cs b/InstarBot/Services/AWSDynamicConfigService.cs index 1b8d7f4..842d81d 100644 --- a/InstarBot/Services/AWSDynamicConfigService.cs +++ b/InstarBot/Services/AWSDynamicConfigService.cs @@ -67,7 +67,7 @@ public async Task GetConfig() { await _pollSemaphore.WaitAsync(); if (_timeProvider.GetUtcNow().UtcDateTime > _nextPollTime) - await Poll(false); + await Poll(); return _current; } @@ -100,10 +100,12 @@ public async Task Initialize() }); _nextToken = configSession.InitialConfigurationToken; - await Poll(true); + await Poll(); } - private async Task Poll(bool bypass) + private const string ServiceName = "Dynamic Config"; + + private async Task Poll() { try { @@ -114,23 +116,23 @@ private async Task Poll(bool bypass) _nextToken = result.NextPollConfigurationToken; _nextPollTime = _timeProvider.GetUtcNow().UtcDateTime + TimeSpan.FromSeconds((double)(result.NextPollIntervalInSeconds ?? 60)); - - // Per the documentation, if VersionLabel is empty, then the client - // has the most up-to-date configuration already stored. We can stop - // here. - if (!bypass && string.IsNullOrEmpty(result.VersionLabel)) - return; - if (!string.IsNullOrEmpty(result.VersionLabel)) - Log.Information("Downloading latest configuration version {ConfigVersion} from AppConfig...", result.VersionLabel); - else - Log.Information("Downloading latest configuration from AppConfig..."); + Log.Debug("[{ServiceName}] Next polling time: {NextPollTime}", ServiceName, _nextPollTime); + + // Per the documentation, if configuration is empty, then the client + // has the most up-to-date configuration already stored. We can stop + // here. + if (result.Configuration is null) + { + Log.Verbose("[{ServiceName}] No new configuration is available.", ServiceName); + return; + } _configData = Encoding.UTF8.GetString(result.Configuration.ToArray()); _current = JsonConvert.DeserializeObject(_configData) ?? throw new ConfigurationException("Failed to parse configuration"); - Log.Information("Done downloading latest configuration!"); + Log.Information("[{ServiceName}] New configuration downloaded from AppConfig!", ServiceName); } catch (Exception ex) { diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index def17c3..4769707 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -178,6 +178,7 @@ private async Task HandleUserJoined(IGuildUser user) await _metricService.Emit(Metric.Discord_UsersJoined, 1); } + private async Task HandleUserLeft(IUser arg) { // TODO: Maybe handle something here later @@ -226,6 +227,25 @@ private async Task HandleUserUpdated(UserUpdatedEventArgs arg) Log.Information("Updated metadata for user {Username} (user ID {UserID})", arg.After.Username, arg.ID); await user.CommitAsync(); } + + // Does the user have any of the auto kick roles? + try + { + var cfg = await _dynamicConfig.GetConfig(); + + if (cfg.AutoKickRoles is null) + return; + + if (!cfg.AutoKickRoles.ContainsAny(arg.After.RoleIds.Select(n => new Snowflake(n)).ToArray())) + return; + + await arg.After.KickAsync("Automatically kicked for having a forbidden role."); + await _metricService.Emit(Metric.AMS_ForbiddenRoleKicks, 1); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to determine if user {UserID} has any forbidden roles.", arg.ID.ID); + } } public override async Task RunAsync() diff --git a/InstarBot/Services/BirthdaySystem.cs b/InstarBot/Services/BirthdaySystem.cs index 6f4aba4..49e99d5 100644 --- a/InstarBot/Services/BirthdaySystem.cs +++ b/InstarBot/Services/BirthdaySystem.cs @@ -16,6 +16,9 @@ public sealed class BirthdaySystem ( TimeProvider timeProvider) : ScheduledService("*/5 * * * *", timeProvider, metricService, "Birthday System"), IBirthdaySystem { + private readonly TimeProvider _timeProvider = timeProvider; + private readonly IMetricService _metricService = metricService; + /// /// The maximum age to be considered 'valid' for age role assignment. /// @@ -31,12 +34,12 @@ internal override Task Initialize() public override async Task RunAsync() { var cfg = await dynamicConfig.GetConfig(); - var currentTime = timeProvider.GetUtcNow().UtcDateTime; + var currentTime = _timeProvider.GetUtcNow().UtcDateTime; await RemoveBirthdays(cfg, currentTime); var successfulAdds = await GrantBirthdays(cfg, currentTime); - await metricService.Emit(Metric.BirthdaySystem_Grants, successfulAdds.Count); + await _metricService.Emit(Metric.BirthdaySystem_Grants, successfulAdds.Count); // Now we can create a happy announcement message if (successfulAdds.Count == 0) @@ -129,7 +132,7 @@ private async Task> GrantBirthdays(InstarDynamicConfiguration cf catch (Exception ex) { Log.Error(ex, "Failed to run birthday routine"); - await metricService.Emit(Metric.BirthdaySystem_Failures, 1); + await _metricService.Emit(Metric.BirthdaySystem_Failures, 1); return [ ]; } diff --git a/InstarBot/Services/CloudwatchMetricService.cs b/InstarBot/Services/CloudwatchMetricService.cs index 62c8e60..051b399 100644 --- a/InstarBot/Services/CloudwatchMetricService.cs +++ b/InstarBot/Services/CloudwatchMetricService.cs @@ -1,17 +1,17 @@ -using System.Collections.Specialized; -using System.Net; using Amazon; using Amazon.CloudWatch; using Amazon.CloudWatch.Model; using Amazon.Runtime; +using JetBrains.Annotations; using Microsoft.Extensions.Configuration; -using PaxAndromeda.Instar; using PaxAndromeda.Instar.Metrics; using Serilog; +using System.Net; using Metric = PaxAndromeda.Instar.Metrics.Metric; namespace PaxAndromeda.Instar.Services; +[UsedImplicitly] public sealed class CloudwatchMetricService : IMetricService { // Exponential backoff parameters diff --git a/InstarBot/Services/DiscordService.cs b/InstarBot/Services/DiscordService.cs index 0ec0a34..3854682 100644 --- a/InstarBot/Services/DiscordService.cs +++ b/InstarBot/Services/DiscordService.cs @@ -318,6 +318,6 @@ public async IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime } } - public async Task GetChannel(Snowflake channelId) + public async Task GetChannel(Snowflake channelId) => await _socketClient.GetChannelAsync(channelId); } \ No newline at end of file diff --git a/InstarBot/Services/FileSystemMetricService.cs b/InstarBot/Services/FileSystemMetricService.cs index 6359d97..50a2123 100644 --- a/InstarBot/Services/FileSystemMetricService.cs +++ b/InstarBot/Services/FileSystemMetricService.cs @@ -1,9 +1,11 @@ -using System.Reflection; +using JetBrains.Annotations; using PaxAndromeda.Instar.Metrics; using Serilog; +using System.Reflection; namespace PaxAndromeda.Instar.Services; +[UsedImplicitly] public class FileSystemMetricService : IMetricService { public FileSystemMetricService() diff --git a/InstarBot/Services/IDatabaseService.cs b/InstarBot/Services/IDatabaseService.cs index 8653cf8..2229296 100644 --- a/InstarBot/Services/IDatabaseService.cs +++ b/InstarBot/Services/IDatabaseService.cs @@ -59,14 +59,7 @@ public interface IDatabaseService // TODO: documentation Task>> GetPendingNotifications(); - /// - /// Creates a new database representation from a provided . - /// - /// An instance of . - /// An instance of . - /// - /// When a new user is created with this method, it is *not* created in DynamoDB until - /// is called. - /// Task> CreateNotificationAsync(Notification notification); + + Task>> GetNotificationsByTypeAndReferenceUser(NotificationType type, Snowflake userId); } \ No newline at end of file diff --git a/InstarBot/Services/IDiscordService.cs b/InstarBot/Services/IDiscordService.cs index fe6c5f9..2302cf7 100644 --- a/InstarBot/Services/IDiscordService.cs +++ b/InstarBot/Services/IDiscordService.cs @@ -23,7 +23,7 @@ public interface IDiscordService Task Start(IServiceProvider provider); IInstarGuild GetGuild(); Task> GetAllUsers(); - Task GetChannel(Snowflake channelId); + Task GetChannel(Snowflake channelId); IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime); IGuildUser? GetUser(Snowflake snowflake); IEnumerable GetAllUsersWithRole(Snowflake roleId); diff --git a/InstarBot/Services/IRunnableService.cs b/InstarBot/Services/IRunnableService.cs index af7674b..48d721d 100644 --- a/InstarBot/Services/IRunnableService.cs +++ b/InstarBot/Services/IRunnableService.cs @@ -5,5 +5,5 @@ public interface IRunnableService /// /// Runs the service. /// - Task RunAsync(); + public Task RunAsync(); } \ No newline at end of file diff --git a/InstarBot/Services/IScheduledService.cs b/InstarBot/Services/IScheduledService.cs index 3fe7f46..62f4d55 100644 --- a/InstarBot/Services/IScheduledService.cs +++ b/InstarBot/Services/IScheduledService.cs @@ -1,5 +1,3 @@ namespace PaxAndromeda.Instar.Services; -public interface IScheduledService : IStartableService, IRunnableService -{ -} \ No newline at end of file +public interface IScheduledService : IStartableService, IRunnableService; \ No newline at end of file diff --git a/InstarBot/Services/IStartableService.cs b/InstarBot/Services/IStartableService.cs index 4213947..06998ae 100644 --- a/InstarBot/Services/IStartableService.cs +++ b/InstarBot/Services/IStartableService.cs @@ -5,5 +5,5 @@ public interface IStartableService /// /// Starts the scheduled service. /// - Task Start(); + public Task Start(); } \ No newline at end of file diff --git a/InstarBot/Services/InstarDynamoDBService.cs b/InstarBot/Services/InstarDynamoDBService.cs index a847162..eb27433 100644 --- a/InstarBot/Services/InstarDynamoDBService.cs +++ b/InstarBot/Services/InstarDynamoDBService.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; using Amazon; using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.DataModel; @@ -54,9 +55,9 @@ public async Task> GetOrCreateUserAsync(IGui return new InstarDatabaseEntry(_ddbContext, data); } - public async Task> CreateNotificationAsync(Notification notification) + public Task> CreateNotificationAsync(Notification notification) { - return new InstarDatabaseEntry(_ddbContext, notification); + return Task.FromResult(new InstarDatabaseEntry(_ddbContext, notification)); } public async Task>> GetBatchUsersAsync(IEnumerable snowflakes) @@ -72,7 +73,7 @@ public async Task>> GetBatchUsersAsync( public async Task>> GetPendingNotifications() { - var currentTime = _timeProvider.GetUtcNow(); + var currentTime = _timeProvider.GetUtcNow().UtcDateTime; var config = new QueryOperationConfig { @@ -86,7 +87,7 @@ public async Task>> GetPendingNotificatio ExpressionAttributeValues = new Dictionary { [":g"] = _guildId, - [":now"] = currentTime.ToString("O") + [":now"] = Utilities.ToDynamoDBCompatibleDateTime(currentTime) } } }; @@ -98,6 +99,39 @@ public async Task>> GetPendingNotificatio return results.Select(u => new InstarDatabaseEntry(_ddbContext, u)).ToList(); } + public async Task>> GetNotificationsByTypeAndReferenceUser(NotificationType type, Snowflake userId) + { + var attr = type.GetAttributeOfType(); + if (attr is null) + throw new ArgumentException("Notification type enum value must have an EnumMember attribute.", nameof(type)); + + var attrVal = attr.Value ?? throw new ArgumentException("Notification type enum value must have an EnumMember attribute with Value defined.", nameof(type)); + + var opConfig = new QueryOperationConfig + { + IndexName = "gsi_type_referenceuser", + KeyExpression = new Expression + { + ExpressionStatement = "guild_id = :g AND #TYPE = :ty AND reference_user = :uid", + ExpressionAttributeNames = new Dictionary + { + ["#TYPE"] = "type" + }, + ExpressionAttributeValues = new Dictionary + { + [":g"] = _guildId, + [":ty"] = attrVal, + [":uid"] = userId.ID.ToString() + } + } + }; + + var qry = _ddbContext.FromQueryAsync(opConfig); + var results = await qry.GetRemainingAsync(); + + return results.Select(n => new InstarDatabaseEntry(_ddbContext, n)).ToList(); + } + public async Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness) { var birthday = new Birthday(birthdate, _timeProvider); @@ -125,25 +159,21 @@ public async Task>> GetUsersByBirthday( var results = new List>(); - foreach (var range in ranges) + foreach (var search in ranges.Select(range => new QueryOperationConfig + { + IndexName = "birthdate-gsi", + KeyExpression = new Expression + { + ExpressionStatement = "guild_id = :g AND birthdate BETWEEN :from AND :to", + ExpressionAttributeValues = new Dictionary + { + [":g"] = _guildId, + [":from"] = range.From, + [":to"] = range.To + } + } + }).Select(config => _ddbContext.FromQueryAsync(config))) { - var config = new QueryOperationConfig - { - IndexName = "birthdate-gsi", - KeyExpression = new Expression - { - ExpressionStatement = "guild_id = :g AND birthdate BETWEEN :from AND :to", - ExpressionAttributeValues = new Dictionary - { - [":g"] = _guildId, - [":from"] = range.From, - [":to"] = range.To - } - } - }; - - var search = _ddbContext.FromQueryAsync(config); - var page = await search.GetRemainingAsync().ConfigureAwait(false); results.AddRange(page.Select(u => new InstarDatabaseEntry(_ddbContext, u))); } diff --git a/InstarBot/Services/NTPService.cs b/InstarBot/Services/NTPService.cs new file mode 100644 index 0000000..b9dee1a --- /dev/null +++ b/InstarBot/Services/NTPService.cs @@ -0,0 +1,71 @@ +using GuerrillaNtp; +using PaxAndromeda.Instar.Metrics; +using Serilog; + +namespace PaxAndromeda.Instar.Services; + +/// +/// This service monitors and emits the clock drift metric which helps +/// operators be aware of a clock issue. +/// +/// +/// Why? Many of our interactions have a time limit of 3 seconds for a +/// response. This is enforced locally with Discord.NET. It has been +/// observed that if there is a significant clock drift from UTC time, +/// the bot can enter an undefined state where it cannot process +/// interaction commands any further. +/// +/// Whilst this is unlikely to occur on a dedicated blade server from +/// a hosting provider, having a metric for this to root cause issues +/// is crucial for investigatory purposes. +/// +public class NTPService (TimeProvider timeProvider, IMetricService metricService) + : ScheduledService("*/5 * * * *", timeProvider, metricService, "NTP Service") +{ + /// + /// The hostname for NIST's NTP servers. We will trust them as the + /// authority on time. + /// + private const string Hostname = "time.nist.gov"; + + private readonly IMetricService _metricService = metricService; + private NtpClient _ntpClient = null!; + + internal override Task Initialize() + { + _ntpClient = new NtpClient(Hostname); + + return Task.CompletedTask; + } + + public override async Task RunAsync() + { + try + { + var result = await _ntpClient.QueryAsync(); + + if (result is null) + { + Log.Error("Failed to query NTP time from {Hostname}.", Hostname); + await _metricService.Emit(Metric.NTP_Error, 1); + return; + } + + await _metricService.Emit(Metric.NTP_Drift, result.CorrectionOffset.TotalMicroseconds); + + Log.Debug("[{ServiceName}] Time Drift: {TimeDrift:N}µs", "NTP", result.CorrectionOffset.TotalMicroseconds); + + } catch (Exception ex) + { + Log.Error(ex, "Failed to query NTP time from {Hostname}.", Hostname); + + try + { + await _metricService.Emit(Metric.NTP_Error, 1); + } catch (Exception ex2) + { + Log.Error(ex2, "Failed to emit NTP query error metric."); + } + } + } +} \ No newline at end of file diff --git a/InstarBot/Services/NotificationService.cs b/InstarBot/Services/NotificationService.cs index 76a9ddb..e438c6e 100644 --- a/InstarBot/Services/NotificationService.cs +++ b/InstarBot/Services/NotificationService.cs @@ -1,26 +1,28 @@ -using PaxAndromeda.Instar.DynamoModels; +using Discord; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Embeds; +using PaxAndromeda.Instar.Metrics; using Serilog; +using System.Text; namespace PaxAndromeda.Instar.Services; -public class NotificationService : ScheduledService +public interface INotificationService : IScheduledService { - private readonly IDatabaseService _dbService; - private readonly IDiscordService _discordService; - private readonly IDynamicConfigService _dynamicConfig; - - public NotificationService( - TimeProvider timeProvider, - IMetricService metricService, - IDatabaseService dbService, - IDiscordService discordService, - IDynamicConfigService dynamicConfig) - : base("* * * * *", timeProvider, metricService, "Notifications Service") - { - _dbService = dbService; - _discordService = discordService; - _dynamicConfig = dynamicConfig; - } + Task QueueNotification(Notification notification, TimeSpan delay); +} + +public class NotificationService ( + TimeProvider timeProvider, + IMetricService metricService, + IDatabaseService dbService, + IDiscordService discordService, + IDynamicConfigService dynamicConfig) + : ScheduledService("* * * * *", timeProvider, metricService, "Notifications Service"), INotificationService +{ + private readonly TimeProvider _timeProvider = timeProvider; + private readonly IMetricService _metricService = metricService; internal override Task Initialize() { @@ -30,21 +32,100 @@ internal override Task Initialize() public override async Task RunAsync() { var notificationQueue = new Queue>( - (await _dbService.GetPendingNotifications()).OrderByDescending(entry => entry.Data.Date) + (await dbService.GetPendingNotifications()).OrderByDescending(entry => entry.Data.Date) ); + var cfg = await dynamicConfig.GetConfig(); + + await discordService.SyncUsers(); + while (notificationQueue.TryDequeue(out var notification)) { - if (!await ProcessNotification(notification)) + if (!await ProcessNotification(notification, cfg)) continue; await notification.DeleteAsync(); } } - private async Task ProcessNotification(InstarDatabaseEntry notification) + private async Task ProcessNotification(InstarDatabaseEntry notification, InstarDynamicConfiguration cfg) { - Log.Information("Processed notification from {Actor} sent on {Date}: {Message}", notification.Data.Actor.ID, notification.Data.Date, notification.Data.Data.Message); - return true; + try + { + Log.Information("Processed notification from {Actor} sent on {Date}: {Message}", notification.Data.Actor.ID, notification.Data.Date, notification.Data.Data.Message); + + IChannel? channel = await discordService.GetChannel(notification.Data.Channel); + if (channel is not ITextChannel textChannel) + { + Log.Error("Failed to send notification dated {NotificationDate}: channel {ChannelId} does not exist", notification.Data.Date, notification.Data.Channel); + await _metricService.Emit(Metric.Notification_MalformedNotification, 1); + return true; + } + + var actor = discordService.GetUser(notification.Data.Actor); + + Log.Debug("Actor ID {UserId} name: {Username}", notification.Data.Actor.ID, actor?.Username ?? ""); + + var embed = new NotificationEmbed(notification.Data, actor, cfg); + + await textChannel.SendMessageAsync(GetNotificationTargetString(notification.Data), embed: embed.Build()); + await _metricService.Emit(Metric.Notification_NotificationsSent, 1); + + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to send notification dated {NotificationDate}: an unknown error has occurred", notification.Data.Date); + await _metricService.Emit(Metric.Notification_NotificationsFailed, 1); + + try + { + // Let's try to mark the notification for a reattempt + notification.Data.SendAttempts++; + await notification.CommitAsync(); + } catch (Exception ex2) + { + Log.Error(ex2, "Failed to update notification dated {NotificationDate}: cannot increment send attempts", notification.Data.Date); + } + } + + return false; + } + + private static string GetNotificationTargetString(Notification notificationData) + { + return notificationData.Targets.Count switch + { + 1 => GetMention(notificationData.Targets.First()), + >= 2 => string.Join(' ', notificationData.Targets.Select(GetMention)).TrimEnd(), + _ => string.Empty + }; + } + + private static string GetMention(NotificationTarget target) + { + StringBuilder builder = new(); + builder.Append(target.Type switch + { + NotificationTargetType.User => "<@", + NotificationTargetType.Role => "<@&", + _ => "<@" + }); + + builder.Append(target.Id.ID); + builder.Append(">"); + + return builder.ToString(); + } + + public async Task QueueNotification(Notification notification, TimeSpan delay) + { + var cfg = await dynamicConfig.GetConfig(); + + notification.Date = _timeProvider.GetUtcNow().UtcDateTime + delay; + notification.GuildID = cfg.TargetGuild; + + var notificationEntry = await dbService.CreateNotificationAsync(notification); + await notificationEntry.CommitAsync(); } } \ No newline at end of file diff --git a/InstarBot/Snowflake.cs b/InstarBot/Snowflake.cs index fe39727..8d27aa0 100644 --- a/InstarBot/Snowflake.cs +++ b/InstarBot/Snowflake.cs @@ -32,6 +32,8 @@ public sealed record Snowflake { private static int _increment; + public static readonly DateTime Epoch = new(2015, 1, 1, 0, 0, 0, DateTimeKind.Utc); + /// /// The Discord epoch, defined as the first second of the year 2015. /// diff --git a/InstarBot/Strings.Designer.cs b/InstarBot/Strings.Designer.cs index d234ab7..ec73c1a 100644 --- a/InstarBot/Strings.Designer.cs +++ b/InstarBot/Strings.Designer.cs @@ -105,6 +105,17 @@ public static string Command_AutoMemberHold_Error_Unexpected { } } + /// + /// Looks up a localized string similar to The user listed below has their membership withheld for one week. Please review this member and remove the hold if no further justification exists. + /// + ///No further reminders regarding this user's hold will be sent.. + /// + public static string Command_AutoMemberHold_NotificationMessage { + get { + return ResourceManager.GetString("Command_AutoMemberHold_NotificationMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Membership for user <@{0}> has been withheld. Staff will be notified in one week to review.. /// @@ -600,6 +611,15 @@ public static string Embed_BirthdaySystem_Footer { } } + /// + /// Looks up a localized string similar to Instar Notification System. + /// + public static string Embed_Notification_Footer { + get { + return ResourceManager.GetString("Embed_Notification_Footer", resourceCulture); + } + } + /// /// Looks up a localized string similar to Instar Paging System. /// diff --git a/InstarBot/Strings.resx b/InstarBot/Strings.resx index 427b42b..302548d 100644 --- a/InstarBot/Strings.resx +++ b/InstarBot/Strings.resx @@ -284,10 +284,6 @@ Instar Message Reporting System - - :birthday: <@&{0}> {1}! - {0} is the ID of the Happy Birthday role, {1} is the list of recipients. - and @@ -351,4 +347,16 @@ As the user is a new member, their membership has automatically been withheld pe Successfully reset the birthday of <@{0}>, but the birthday role could not be automatically removed. The user has been notified of the reset by DM. {0} is the user ID. + + :birthday: <@&{0}> {1}! + {0} is the ID of the Happy Birthday role, {1} is the list of recipients. + + + Instar Notification System + + + The user listed below has their membership withheld for one week. Please review this member and remove the hold if no further justification exists. + +No further reminders regarding this user's hold will be sent. + \ No newline at end of file diff --git a/InstarBot/Utilities.cs b/InstarBot/Utilities.cs index 66662bf..d4cb187 100644 --- a/InstarBot/Utilities.cs +++ b/InstarBot/Utilities.cs @@ -152,4 +152,26 @@ public static string ScreamingToPascalCase(string input) /// A string containing the UTC month, day, hour, and minute of the Birthdate in the format "MMddHHmm". public static string ToBirthdateKey(DateTimeOffset dt) => dt.ToUniversalTime().ToString("MMddHHmm", CultureInfo.InvariantCulture); + + /// + /// A format string for millisecond precision ISO-8601 representations of a DateTime. + /// + /// + /// This constant is aligned with the AWS SDK's equivalent constant located here. + /// + private const string ISO8601DateFormat = @"yyyy-MM-dd\THH:mm:ss.fff\Z"; + + /// + /// Converts a DateTime into a DynamoDB compatible ISO-8601 string. + /// + /// The datetime to convert. + /// A millisecond precision ISO-8601 representation of . + /// + /// This method is aligned with the AWS SDK's equivalent method located here. + /// + public static string ToDynamoDBCompatibleDateTime(DateTime dateTime) + { + var utc = dateTime.ToUniversalTime(); + return utc.ToString(ISO8601DateFormat, CultureInfo.InvariantCulture); + } } \ No newline at end of file From ebde0439a499ace66104a12823c0f54ca34ff667 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sun, 6 Jul 2025 10:06:50 -0700 Subject: [PATCH 12/53] Upgrade to .NET 9.0 and refactor codebase - Upgraded project files to .NET 9.0. - Updated package references for compatibility. - Refactored `TestChannel`, `TestGuild`, and various command classes to use constructor parameters. - Enhanced nullability handling and improved property initializations. - Simplified `MockGaiusAPIService` and `GaiusAPIService` initialization logic. - Changed `Snowflake` from a struct to a record for better usability. - Cleaned up unused methods in `Utilities` class. - Overall improvements in code clarity, maintainability, and adherence to modern .NET practices. --- .../InstarBot.Tests.Common.csproj | 8 +- InstarBot.Tests.Common/Models/TestChannel.cs | 97 +++++++---------- InstarBot.Tests.Common/Models/TestGuild.cs | 6 +- .../Models/TestGuildUser.cs | 18 ++++ InstarBot.Tests.Common/Models/TestMessage.cs | 55 +++++----- InstarBot.Tests.Common/Models/TestRole.cs | 10 +- .../Services/MockGaiusAPIService.cs | 41 +++---- .../Services/MockInstarDDBService.cs | 12 +-- .../Services/MockMetricService.cs | 2 +- InstarBot.Tests.Common/TestContext.cs | 8 +- InstarBot.Tests.Common/TestUtilities.cs | 39 ++++--- .../Features/AutoMemberSystem.feature | 1 - InstarBot.Tests.Integration/Hooks/Hook.cs | 17 +-- .../InstarBot.Tests.Integration.csproj | 16 +-- .../Steps/AutoMemberSystemStepDefinitions.cs | 102 +++++++++--------- .../Steps/BirthdayCommandStepDefinitions.cs | 39 +++---- .../Steps/PageCommandStepDefinitions.cs | 61 +++++------ .../Steps/PingCommandStepDefinitions.cs | 11 +- .../Steps/ReportUserCommandStepDefinitions.cs | 55 +++++----- .../InstarBot.Tests.Unit.csproj | 16 +-- .../RequireStaffMemberAttributeTests.cs | 16 +-- InstarBot.sln.DotSettings | 17 ++- InstarBot/AsyncEvent.cs | 9 +- InstarBot/Caching/MemoryCache.cs | 5 +- InstarBot/Commands/CheckEligibilityCommand.cs | 28 ++--- InstarBot/Commands/PageCommand.cs | 19 +--- InstarBot/Commands/ReportUserCommand.cs | 18 +--- InstarBot/Commands/SetBirthdayCommand.cs | 71 ++++++------ .../TriggerAutoMemberSystemCommand.cs | 11 +- InstarBot/ConfigurationException.cs | 7 +- InstarBot/Gaius/Caselog.cs | 6 +- InstarBot/Gaius/Warning.cs | 8 +- InstarBot/InstarBot.csproj | 38 +++---- InstarBot/MessageProperties.cs | 15 +-- InstarBot/Metrics/MetricDimensionAttribute.cs | 12 +-- InstarBot/Metrics/MetricNameAttribute.cs | 9 +- InstarBot/PageTarget.cs | 3 +- InstarBot/Program.cs | 11 +- InstarBot/Services/AWSDynamicConfigService.cs | 2 +- InstarBot/Services/AutoMemberSystem.cs | 23 ++-- InstarBot/Services/CloudwatchMetricService.cs | 5 +- InstarBot/Services/DiscordService.cs | 8 +- InstarBot/Services/GaiusAPIService.cs | 19 ++-- InstarBot/Services/InstarDDBService.cs | 4 +- InstarBot/Services/TeamService.cs | 19 ++-- InstarBot/Snowflake.cs | 2 +- InstarBot/SnowflakeTypeAttribute.cs | 9 +- InstarBot/TeamRefAttribute.cs | 9 +- .../MessageCommandInteractionWrapper.cs | 17 +-- InstarBot/Wrappers/SocketGuildWrapper.cs | 9 +- 50 files changed, 450 insertions(+), 593 deletions(-) diff --git a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj index 4d6c31c..90dc105 100644 --- a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj +++ b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj @@ -1,7 +1,7 @@ - net6.0 + net9.0 enable enable InstarBot.Tests @@ -10,9 +10,9 @@ - - - + + + diff --git a/InstarBot.Tests.Common/Models/TestChannel.cs b/InstarBot.Tests.Common/Models/TestChannel.cs index b167901..ea9224b 100644 --- a/InstarBot.Tests.Common/Models/TestChannel.cs +++ b/InstarBot.Tests.Common/Models/TestChannel.cs @@ -9,16 +9,10 @@ namespace InstarBot.Tests.Models; [SuppressMessage("ReSharper", "ReplaceAutoPropertyWithComputedProperty")] -public sealed class TestChannel : ITextChannel +public sealed class TestChannel(Snowflake id) : ITextChannel { - public TestChannel(Snowflake id) - { - Id = id; - CreatedAt = id.Time; - } - - public ulong Id { get; } - public DateTimeOffset CreatedAt { get; } + public ulong Id { get; } = id; + public DateTimeOffset CreatedAt { get; } = id.Time; public Task ModifyAsync(Action func, RequestOptions options = null) { @@ -66,10 +60,10 @@ Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode, RequestOpt throw new NotImplementedException(); } - public int Position { get; } = default!; + public int Position { get; } = 0; public ChannelFlags Flags { get; } = default!; public IGuild Guild { get; } = null!; - public ulong GuildId { get; } = default!; + public ulong GuildId { get; } = 0; public IReadOnlyCollection PermissionOverwrites { get; } = null!; IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) @@ -84,50 +78,6 @@ Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions optio public string Name { get; } = null!; - public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, - RequestOptions options = null, - AllowedMentions allowedMentions = null, MessageReference messageReference = null, - MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, - MessageFlags flags = MessageFlags.None) - { - throw new NotImplementedException(); - } - - public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, - RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, - MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, - Embed[] embeds = null, MessageFlags flags = MessageFlags.None) - { - throw new NotImplementedException(); - } - - public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, - Embed embed = null, - RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, - MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, - Embed[] embeds = null, MessageFlags flags = MessageFlags.None) - { - throw new NotImplementedException(); - } - - public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, - Embed embed = null, - RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, - MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, - MessageFlags flags = MessageFlags.None) - { - throw new NotImplementedException(); - } - - public Task SendFilesAsync(IEnumerable attachments, string text = null, - bool isTTS = false, Embed embed = null, - RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, - MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, - MessageFlags flags = MessageFlags.None) - { - throw new NotImplementedException(); - } - public Task GetMessageAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) { @@ -236,7 +186,7 @@ public Task> GetInvitesAsync(RequestOptions throw new NotImplementedException(); } - public ulong? CategoryId { get; } = default!; + public ulong? CategoryId { get; } = null; public Task CreateWebhookAsync(string name, Stream avatar = null, RequestOptions options = null) { @@ -280,15 +230,44 @@ public Task> GetActiveThreadsAsync(RequestOp throw new NotImplementedException(); } - public bool IsNsfw { get; } = default!; + public bool IsNsfw { get; } = false; public string Topic { get; } = null!; - public int SlowModeInterval { get; } = default!; + public int SlowModeInterval { get; } = 0; public ThreadArchiveDuration DefaultArchiveDuration { get; } = default!; - private readonly List _messages = new(); + public int DefaultSlowModeInterval => throw new NotImplementedException(); + + public ChannelType ChannelType => throw new NotImplementedException(); + + private readonly List _messages = []; public void AddMessage(IGuildUser user, string message) { _messages.Add(new TestMessage(user, message)); } + + public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + throw new NotImplementedException(); + } + + public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + throw new NotImplementedException(); + } + + public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + throw new NotImplementedException(); + } + + public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + throw new NotImplementedException(); + } + + public Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestGuild.cs b/InstarBot.Tests.Common/Models/TestGuild.cs index aba1a62..5aa293d 100644 --- a/InstarBot.Tests.Common/Models/TestGuild.cs +++ b/InstarBot.Tests.Common/Models/TestGuild.cs @@ -7,11 +7,11 @@ namespace InstarBot.Tests.Models; public class TestGuild : IInstarGuild { public ulong Id { get; init; } - public IEnumerable TextChannels { get; init; } = default!; + public IEnumerable TextChannels { get; init; } = null!; - public IEnumerable Roles { get; init; } = default!; + public IEnumerable Roles { get; init; } = null!; - public IEnumerable Users { get; init; } = default!; + public IEnumerable Users { get; init; } = null!; public virtual ITextChannel GetTextChannel(ulong channelId) { diff --git a/InstarBot.Tests.Common/Models/TestGuildUser.cs b/InstarBot.Tests.Common/Models/TestGuildUser.cs index e45ba37..7e1a21f 100644 --- a/InstarBot.Tests.Common/Models/TestGuildUser.cs +++ b/InstarBot.Tests.Common/Models/TestGuildUser.cs @@ -142,6 +142,16 @@ public Task RemoveTimeOutAsync(RequestOptions options = null) throw new NotImplementedException(); } + public string GetGuildBannerUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + { + throw new NotImplementedException(); + } + + public string GetAvatarDecorationUrl() + { + throw new NotImplementedException(); + } + public DateTimeOffset? JoinedAt { get; init; } public string DisplayName { get; set; } = null!; public string Nickname { get; set; } = null!; @@ -167,4 +177,12 @@ public IReadOnlyCollection RoleIds /// Test flag indicating the user has been changed. /// public bool Changed { get; private set; } + + public string GuildBannerHash => throw new NotImplementedException(); + + public string GlobalName => throw new NotImplementedException(); + + public string AvatarDecorationHash => throw new NotImplementedException(); + + public ulong? AvatarDecorationSkuId => throw new NotImplementedException(); } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestMessage.cs b/InstarBot.Tests.Common/Models/TestMessage.cs index 2d07119..6f51fbb 100644 --- a/InstarBot.Tests.Common/Models/TestMessage.cs +++ b/InstarBot.Tests.Common/Models/TestMessage.cs @@ -1,10 +1,8 @@ -using System.Diagnostics.CodeAnalysis; using Discord; using PaxAndromeda.Instar; namespace InstarBot.Tests.Models; -[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] public sealed class TestMessage : IMessage { @@ -51,38 +49,41 @@ public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions options throw new NotImplementedException(); } - public IAsyncEnumerable> GetReactionUsersAsync(IEmote emoji, int limit, - RequestOptions options = null!) + public IAsyncEnumerable> GetReactionUsersAsync(IEmote emoji, int limit, RequestOptions? options = null, ReactionType type = ReactionType.Normal) { throw new NotImplementedException(); } public MessageType Type { get; set; } = default; public MessageSource Source { get; set; } = default; - public bool IsTTS { get; set; } = default; - public bool IsPinned { get; set; } = default; - public bool IsSuppressed { get; set; } = default; - public bool MentionedEveryone { get; set; } = default; + public bool IsTTS { get; set; } = false; + public bool IsPinned { get; set; } = false; + public bool IsSuppressed { get; set; } = false; + public bool MentionedEveryone { get; set; } = false; public string Content { get; set; } - public string CleanContent { get; set; } = default!; + public string CleanContent { get; set; } = null!; public DateTimeOffset Timestamp { get; set; } - public DateTimeOffset? EditedTimestamp { get; set; } = default; - public IMessageChannel Channel { get; set; } = default!; + public DateTimeOffset? EditedTimestamp { get; set; } = null; + public IMessageChannel Channel { get; set; } = null!; public IUser Author { get; set; } - public IThreadChannel Thread { get; set; } = default!; - public IReadOnlyCollection Attachments { get; set; } = default!; - public IReadOnlyCollection Embeds { get; set; } = default!; - public IReadOnlyCollection Tags { get; set; } = default!; - public IReadOnlyCollection MentionedChannelIds { get; set; } = default!; - public IReadOnlyCollection MentionedRoleIds { get; set; } = default!; - public IReadOnlyCollection MentionedUserIds { get; set; } = default!; - public MessageActivity Activity { get; set; } = default!; - public MessageApplication Application { get; set; } = default!; - public MessageReference Reference { get; set; } = default!; - public IReadOnlyDictionary Reactions { get; set; } = default!; - public IReadOnlyCollection Components { get; set; } = default!; - public IReadOnlyCollection Stickers { get; set; } = default!; - public MessageFlags? Flags { get; set; } = default; - public IMessageInteraction Interaction { get; set; } = default!; - public MessageRoleSubscriptionData RoleSubscriptionData { get; set; } = default!; + public IThreadChannel Thread { get; set; } = null!; + public IReadOnlyCollection Attachments { get; set; } = null!; + public IReadOnlyCollection Embeds { get; set; } = null!; + public IReadOnlyCollection Tags { get; set; } = null!; + public IReadOnlyCollection MentionedChannelIds { get; set; } = null!; + public IReadOnlyCollection MentionedRoleIds { get; set; } = null!; + public IReadOnlyCollection MentionedUserIds { get; set; } = null!; + public MessageActivity Activity { get; set; } = null!; + public MessageApplication Application { get; set; } = null!; + public MessageReference Reference { get; set; } = null!; + public IReadOnlyDictionary Reactions { get; set; } = null!; + public IReadOnlyCollection Components { get; set; } = null!; + public IReadOnlyCollection Stickers { get; set; } = null!; + public MessageFlags? Flags { get; set; } = null; + public IMessageInteraction Interaction { get; set; } = null!; + public MessageRoleSubscriptionData RoleSubscriptionData { get; set; } = null!; + + public PurchaseNotification PurchaseNotification => throw new NotImplementedException(); + + public MessageCallData? CallData => throw new NotImplementedException(); } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestRole.cs b/InstarBot.Tests.Common/Models/TestRole.cs index 6bee06b..ae71435 100644 --- a/InstarBot.Tests.Common/Models/TestRole.cs +++ b/InstarBot.Tests.Common/Models/TestRole.cs @@ -42,13 +42,15 @@ public string GetIconUrl() public IGuild Guild { get; set; } = null!; public Color Color { get; set; } = default!; - public bool IsHoisted { get; set; } = default!; - public bool IsManaged { get; set; } = default!; - public bool IsMentionable { get; set; } = default!; + public bool IsHoisted { get; set; } = false; + public bool IsManaged { get; set; } = false; + public bool IsMentionable { get; set; } = false; public string Name { get; set; } = null!; public string Icon { get; set; } = null!; public Emoji Emoji { get; set; } = null!; public GuildPermissions Permissions { get; set; } = default!; - public int Position { get; set; } = default!; + public int Position { get; set; } = 0; public RoleTags Tags { get; set; } = null!; + + public RoleFlags Flags { get; set; } = default!; } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockGaiusAPIService.cs b/InstarBot.Tests.Common/Services/MockGaiusAPIService.cs index 6a58bd6..6ebc20a 100644 --- a/InstarBot.Tests.Common/Services/MockGaiusAPIService.cs +++ b/InstarBot.Tests.Common/Services/MockGaiusAPIService.cs @@ -4,19 +4,12 @@ namespace InstarBot.Tests.Services; -public sealed class MockGaiusAPIService : IGaiusAPIService +public sealed class MockGaiusAPIService( + Dictionary> warnings, + Dictionary> caselogs, + bool inhibit = false) + : IGaiusAPIService { - private readonly Dictionary> _warnings; - private readonly Dictionary> _caselogs; - private readonly bool _inhibit; - - public MockGaiusAPIService(Dictionary> warnings, Dictionary> caselogs, bool inhibit = false) - { - _warnings = warnings; - _caselogs = caselogs; - _inhibit = inhibit; - } - public void Dispose() { // do nothing @@ -24,41 +17,41 @@ public void Dispose() public Task> GetAllWarnings() { - return Task.FromResult>(_warnings.Values.SelectMany(list => list).ToList()); + return Task.FromResult>(warnings.Values.SelectMany(list => list).ToList()); } public Task> GetAllCaselogs() { - return Task.FromResult>(_caselogs.Values.SelectMany(list => list).ToList()); + return Task.FromResult>(caselogs.Values.SelectMany(list => list).ToList()); } public Task> GetWarningsAfter(DateTime dt) { - return Task.FromResult>(from list in _warnings.Values from item in list where item.WarnDate > dt select item); + return Task.FromResult>(from list in warnings.Values from item in list where item.WarnDate > dt select item); } public Task> GetCaselogsAfter(DateTime dt) { - return Task.FromResult>(from list in _caselogs.Values from item in list where item.Date > dt select item); + return Task.FromResult>(from list in caselogs.Values from item in list where item.Date > dt select item); } public Task?> GetWarnings(Snowflake userId) { - if (_inhibit) + if (inhibit) return Task.FromResult?>(null); - return !_warnings.ContainsKey(userId) - ? Task.FromResult?>(Array.Empty()) - : Task.FromResult?>(_warnings[userId]); + return !warnings.TryGetValue(userId, out var warning) + ? Task.FromResult?>([]) + : Task.FromResult?>(warning); } public Task?> GetCaselogs(Snowflake userId) { - if (_inhibit) + if (inhibit) return Task.FromResult?>(null); - return !_caselogs.ContainsKey(userId) - ? Task.FromResult?>(Array.Empty()) - : Task.FromResult?>(_caselogs[userId]); + return !caselogs.TryGetValue(userId, out var caselog) + ? Task.FromResult?>([]) + : Task.FromResult?>(caselog); } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs index 61c3004..f9d9676 100644 --- a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs +++ b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs @@ -44,22 +44,22 @@ public Task UpdateUserMembership(Snowflake snowflake, bool membershipGrant public Task GetUserBirthday(Snowflake snowflake) { - return !_localData.ContainsKey(snowflake) + return !_localData.TryGetValue(snowflake, out var value) ? Task.FromResult(null) - : Task.FromResult(_localData[snowflake].Birthday); + : Task.FromResult(value.Birthday); } public Task GetUserJoinDate(Snowflake snowflake) { - return !_localData.ContainsKey(snowflake) + return !_localData.TryGetValue(snowflake, out var value) ? Task.FromResult(null) - : Task.FromResult(_localData[snowflake].JoinDate); + : Task.FromResult(value.JoinDate); } public Task GetUserMembership(Snowflake snowflake) { - return !_localData.ContainsKey(snowflake) + return !_localData.TryGetValue(snowflake, out var value) ? Task.FromResult(null) - : Task.FromResult(_localData[snowflake].GrantedMembership); + : Task.FromResult(value.GrantedMembership); } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockMetricService.cs b/InstarBot.Tests.Common/Services/MockMetricService.cs index 46817d5..4a9c115 100644 --- a/InstarBot.Tests.Common/Services/MockMetricService.cs +++ b/InstarBot.Tests.Common/Services/MockMetricService.cs @@ -6,7 +6,7 @@ namespace InstarBot.Tests.Services; public sealed class MockMetricService : IMetricService { - private readonly List<(Metric, double)> _emittedMetrics = new(); + private readonly List<(Metric, double)> _emittedMetrics = []; public Task Emit(Metric metric, double value) { diff --git a/InstarBot.Tests.Common/TestContext.cs b/InstarBot.Tests.Common/TestContext.cs index 0dcf8c5..387c2dc 100644 --- a/InstarBot.Tests.Common/TestContext.cs +++ b/InstarBot.Tests.Common/TestContext.cs @@ -14,13 +14,13 @@ public sealed class TestContext public ulong ChannelID { get; init; } = 1420070400200; public ulong GuildID { get; init; } = 1420070400300; - public List UserRoles { get; init; } = new(); + public List UserRoles { get; init; } = []; public Action EmbedCallback { get; init; } = _ => { }; public Mock TextChannelMock { get; internal set; } = null!; - public List GuildUsers { get; init; } = new(); + public List GuildUsers { get; init; } = []; public Dictionary Channels { get; init; } = new(); public Dictionary Roles { get; init; } = new(); @@ -33,7 +33,7 @@ public sealed class TestContext public void AddWarning(Snowflake userId, Warning warning) { if (!Warnings.ContainsKey(userId)) - Warnings.Add(userId, new List { warning }); + Warnings.Add(userId, [warning]); else Warnings[userId].Add(warning); } @@ -41,7 +41,7 @@ public void AddWarning(Snowflake userId, Warning warning) public void AddCaselog(Snowflake userId, Caselog caselog) { if (!Caselogs.ContainsKey(userId)) - Caselogs.Add(userId, new List { caselog }); + Caselogs.Add(userId, [caselog]); else Caselogs[userId].Add(caselog); } diff --git a/InstarBot.Tests.Common/TestUtilities.cs b/InstarBot.Tests.Common/TestUtilities.cs index 4f8416c..5d3b918 100644 --- a/InstarBot.Tests.Common/TestUtilities.cs +++ b/InstarBot.Tests.Common/TestUtilities.cs @@ -65,9 +65,9 @@ public static IServiceProvider GetServices() } /// - /// Provides an method for verifying messages with an ambiguous Mock type. + /// Provides a method for verifying messages with an ambiguous Mock type. /// - /// The mock of the command. + /// A mockup of the command. /// The message to search for. /// A flag indicating whether the message should be ephemeral. public static void VerifyMessage(object mockObject, string message, bool ephemeral = false) @@ -92,13 +92,13 @@ public static void VerifyMessage(object mockObject, string message, bool ephemer .First(); var specificMethod = genericVerifyMessage.MakeGenericMethod(commandType); - specificMethod.Invoke(null, new[] { mockObject, message, ephemeral }); + specificMethod.Invoke(null, [mockObject, message, ephemeral]); } /// /// Verifies that the command responded to the user with the correct . /// - /// The mock of the command. + /// A mockup of the command. /// The message to check for. /// A flag indicating whether the message should be ephemeral. /// The type of command. Must implement . @@ -109,7 +109,7 @@ public static void VerifyMessage(Mock command, string message, bool epheme "RespondAsync", Times.Once(), message, ItExpr.IsAny(), false, ephemeral, ItExpr.IsAny(), ItExpr.IsAny(), - ItExpr.IsAny(), ItExpr.IsAny()); + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } public static IDiscordService SetupDiscordService(TestContext context = null!) @@ -118,7 +118,7 @@ public static IDiscordService SetupDiscordService(TestContext context = null!) public static IGaiusAPIService SetupGaiusAPIService(TestContext context = null!) => new MockGaiusAPIService(context.Warnings, context.Caselogs, context.InhibitGaius); - private static IInstarGuild SetupGuild(TestContext context = null!) + private static TestGuild SetupGuild(TestContext context = null!) { var guild = new TestGuild { @@ -162,7 +162,8 @@ private static void ConfigureCommandMock(Mock mock, TestContext? context) It.IsAny(), It.IsAny(), ItExpr.IsNull(), ItExpr.IsNull(), ItExpr.IsNull(), - ItExpr.IsNull()) + ItExpr.IsNull(), + ItExpr.IsNull()) .Returns(Task.CompletedTask); } @@ -170,10 +171,10 @@ public static Mock SetupContext(TestContext? context) { var mock = new Mock(); - mock.SetupGet(n => n.User!).Returns(SetupUserMock(context).Object); - mock.SetupGet(n => n.Channel!).Returns(SetupChannelMock(context).Object); + mock.SetupGet(static n => n.User!).Returns(SetupUserMock(context).Object); + mock.SetupGet(static n => n.Channel!).Returns(SetupChannelMock(context).Object); // Note: The following line must occur after the mocking of GetChannel. - mock.SetupGet(n => n.Guild).Returns(SetupGuildMock(context).Object); + mock.SetupGet(static n => n.Guild).Returns(SetupGuildMock(context).Object); return mock; } @@ -183,7 +184,7 @@ private static Mock SetupGuildMock(TestContext? context) context.Should().NotBeNull(); var guildMock = new Mock(); - guildMock.Setup(n => n.Id).Returns(context!.GuildID); + guildMock.Setup(n => n.Id).Returns(context.GuildID); guildMock.Setup(n => n.GetTextChannel(It.IsAny())) .Returns(context.TextChannelMock.Object); @@ -230,12 +231,16 @@ private static Mock SetupChannelMock(TestContext? context) channelMock.As().Setup(n => n.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), + It.IsAny(), + It.IsAny())) .Callback((string _, bool _, Embed embed, RequestOptions _, AllowedMentions _, MessageReference _, MessageComponent _, ISticker[] _, Embed[] _, - MessageFlags _) => + MessageFlags _, PollProperties _) => { context.EmbedCallback(embed); }) @@ -254,14 +259,14 @@ public static async IAsyncEnumerable GetTeams(PageTarget pageTarget) teamsConfig.Should().NotBeNull(); var teamRefs = pageTarget.GetAttributesOfType()?.Select(n => n.InternalID) ?? - new List(); + []; foreach (var internalId in teamRefs) { - if (!teamsConfig.ContainsKey(internalId)) + if (!teamsConfig.TryGetValue(internalId, out Team? value)) throw new KeyNotFoundException("Failed to find team with internal ID " + internalId); - yield return teamsConfig[internalId]; + yield return value; } } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Features/AutoMemberSystem.feature b/InstarBot.Tests.Integration/Features/AutoMemberSystem.feature index c59ada2..11c0f80 100644 --- a/InstarBot.Tests.Integration/Features/AutoMemberSystem.feature +++ b/InstarBot.Tests.Integration/Features/AutoMemberSystem.feature @@ -78,7 +78,6 @@ Feature: Auto Member System Then the user should remain unchanged Rule: Users should have all minimum requirements for membership - Scenario: A user that did not post an introduction should not be granted membership Given a user that has: * Joined 36 hours ago diff --git a/InstarBot.Tests.Integration/Hooks/Hook.cs b/InstarBot.Tests.Integration/Hooks/Hook.cs index 02d4810..7cbb8d2 100644 --- a/InstarBot.Tests.Integration/Hooks/Hook.cs +++ b/InstarBot.Tests.Integration/Hooks/Hook.cs @@ -3,28 +3,21 @@ namespace InstarBot.Tests.Integration.Hooks; [Binding] -public class InstarHooks +public class InstarHooks(ScenarioContext scenarioContext) { - private readonly ScenarioContext _scenarioContext; - - public InstarHooks(ScenarioContext scenarioContext) - { - _scenarioContext = scenarioContext; - } - [Then(@"Instar should emit a message stating ""(.*)""")] public void ThenInstarShouldEmitAMessageStating(string message) { - Assert.True(_scenarioContext.ContainsKey("Command")); - var cmdObject = _scenarioContext.Get("Command"); + Assert.True(scenarioContext.ContainsKey("Command")); + var cmdObject = scenarioContext.Get("Command"); TestUtilities.VerifyMessage(cmdObject, message); } [Then(@"Instar should emit an ephemeral message stating ""(.*)""")] public void ThenInstarShouldEmitAnEphemeralMessageStating(string message) { - Assert.True(_scenarioContext.ContainsKey("Command")); - var cmdObject = _scenarioContext.Get("Command"); + Assert.True(scenarioContext.ContainsKey("Command")); + var cmdObject = scenarioContext.Get("Command"); TestUtilities.VerifyMessage(cmdObject, message, true); } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj index 138202f..4f762d8 100644 --- a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj +++ b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj @@ -1,7 +1,7 @@ - net6.0 + net9.0 enable enable @@ -11,17 +11,17 @@ - - - + + + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs index 081b85e..8174ff6 100644 --- a/InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs +++ b/InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs @@ -10,16 +10,10 @@ namespace InstarBot.Tests.Integration; [Binding] -public class AutoMemberSystemStepDefinitions +public class AutoMemberSystemStepDefinitions(ScenarioContext scenarioContext) { - private readonly ScenarioContext _scenarioContext; private readonly Dictionary _roleNameIDMap = new(); - public AutoMemberSystemStepDefinitions(ScenarioContext scenarioContext) - { - _scenarioContext = scenarioContext; - } - [Given("the roles as follows:")] public void GivenTheRolesAsFollows(Table table) { @@ -31,21 +25,21 @@ public void GivenTheRolesAsFollows(Table table) private async Task SetupTest() { - var context = _scenarioContext.Get("Context"); + var context = scenarioContext.Get("Context"); var discordService = TestUtilities.SetupDiscordService(context); var gaiusApiService = TestUtilities.SetupGaiusAPIService(context); var config = TestUtilities.GetDynamicConfiguration(); - _scenarioContext.Add("Config", config); - _scenarioContext.Add("DiscordService", discordService); + scenarioContext.Add("Config", config); + scenarioContext.Add("DiscordService", discordService); - var userId = _scenarioContext.Get("UserID"); - var relativeJoinTime = _scenarioContext.Get("UserAge"); - var roles = _scenarioContext.Get("UserRoles").Select(roleId => new Snowflake(roleId)).ToArray(); - var postedIntro = _scenarioContext.Get("UserPostedIntroduction"); - var messagesLast24Hours = _scenarioContext.Get("UserMessagesPast24Hours"); - var firstSeenTime = _scenarioContext.ContainsKey("UserFirstJoinedTime") ? _scenarioContext.Get("UserFirstJoinedTime") : 0; - var grantedMembershipBefore = _scenarioContext.ContainsKey("UserGrantedMembershipBefore") && _scenarioContext.Get("UserGrantedMembershipBefore"); - var amsConfig = _scenarioContext.Get("AMSConfig"); + var userId = scenarioContext.Get("UserID"); + var relativeJoinTime = scenarioContext.Get("UserAge"); + var roles = scenarioContext.Get("UserRoles").Select(roleId => new Snowflake(roleId)).ToArray(); + var postedIntro = scenarioContext.Get("UserPostedIntroduction"); + var messagesLast24Hours = scenarioContext.Get("UserMessagesPast24Hours"); + var firstSeenTime = scenarioContext.ContainsKey("UserFirstJoinedTime") ? scenarioContext.Get("UserFirstJoinedTime") : 0; + var grantedMembershipBefore = scenarioContext.ContainsKey("UserGrantedMembershipBefore") && scenarioContext.Get("UserGrantedMembershipBefore"); + var amsConfig = scenarioContext.Get("AMSConfig"); var ddbService = new MockInstarDDBService(); if (firstSeenTime > 0) @@ -77,8 +71,8 @@ private async Task SetupTest() var ams = new AutoMemberSystem(config, discordService, gaiusApiService, ddbService, new MockMetricService()); - _scenarioContext.Add("AutoMemberSystem", ams); - _scenarioContext.Add("User", user); + scenarioContext.Add("AutoMemberSystem", ams); + scenarioContext.Add("User", user); return ams; } @@ -94,19 +88,19 @@ public async Task WhenTheAutoMemberSystemProcesses() [Then("the user should remain unchanged")] public void ThenTheUserShouldRemainUnchanged() { - var userId = _scenarioContext.Get("UserID"); - var context = _scenarioContext.Get("Context"); + var userId = scenarioContext.Get("UserID"); + var context = scenarioContext.Get("Context"); var user = context.GuildUsers.First(n => n.Id == userId.ID) as TestGuildUser; user.Should().NotBeNull(); - user!.Changed.Should().BeFalse(); + user.Changed.Should().BeFalse(); } [Given("Been issued a warning")] public void GivenBeenIssuedAWarning() { - var userId = _scenarioContext.Get("UserID"); - var context = _scenarioContext.Get("Context"); + var userId = scenarioContext.Get("UserID"); + var context = scenarioContext.Get("Context"); context.AddWarning(userId, new Warning { @@ -119,8 +113,8 @@ public void GivenBeenIssuedAWarning() [Given("Been issued a mute")] public void GivenBeenIssuedAMute() { - var userId = _scenarioContext.Get("UserID"); - var context = _scenarioContext.Get("Context"); + var userId = scenarioContext.Get("UserID"); + var context = scenarioContext.Get("Context"); context.AddCaselog(userId, new Caselog { @@ -134,7 +128,7 @@ public void GivenBeenIssuedAMute() [Given("the Gaius API is not available")] public void GivenTheGaiusApiIsNotAvailable() { - var context = _scenarioContext.Get("Context"); + var context = scenarioContext.Get("Context"); context.InhibitGaius = true; } @@ -143,44 +137,44 @@ public async Task GivenAUserThatHas() { var config = await TestUtilities.GetDynamicConfiguration().GetConfig(); var amsConfig = config.AutoMemberConfig; - _scenarioContext.Add("AMSConfig", amsConfig); + scenarioContext.Add("AMSConfig", amsConfig); var cmc = new TestContext(); - _scenarioContext.Add("Context", cmc); + scenarioContext.Add("Context", cmc); var userId = Snowflake.Generate(); - _scenarioContext.Add("UserID", userId); + scenarioContext.Add("UserID", userId); } [Given("Joined (.*) hours ago")] - public void GivenJoinedHoursAgo(int ageHours) => _scenarioContext.Add("UserAge", ageHours); + public void GivenJoinedHoursAgo(int ageHours) => scenarioContext.Add("UserAge", ageHours); [Given("The roles (.*)")] public void GivenTheRoles(string roles) { - var roleNames = roles.Split(new[] { ",", "and" }, + var roleNames = roles.Split([",", "and"], StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); var roleIds = roleNames.Select(roleName => _roleNameIDMap[roleName]).ToArray(); - _scenarioContext.Add("UserRoles", roleIds); + scenarioContext.Add("UserRoles", roleIds); } [Given("Posted an introduction")] - public void GivenPostedAnIntroduction() => _scenarioContext.Add("UserPostedIntroduction", true); + public void GivenPostedAnIntroduction() => scenarioContext.Add("UserPostedIntroduction", true); [Given("Did not post an introduction")] - public void GivenDidNotPostAnIntroduction() => _scenarioContext.Add("UserPostedIntroduction", false); + public void GivenDidNotPostAnIntroduction() => scenarioContext.Add("UserPostedIntroduction", false); [Given("Posted (.*) messages in the past day")] - public void GivenPostedMessagesInThePastDay(int numMessages) => _scenarioContext.Add("UserMessagesPast24Hours", numMessages); + public void GivenPostedMessagesInThePastDay(int numMessages) => scenarioContext.Add("UserMessagesPast24Hours", numMessages); [Then("the user should be granted membership")] public async Task ThenTheUserShouldBeGrantedMembership() { - var userId = _scenarioContext.Get("UserID"); - var context = _scenarioContext.Get("Context"); - var config = _scenarioContext.Get("Config"); + var userId = scenarioContext.Get("UserID"); + var context = scenarioContext.Get("Context"); + var config = scenarioContext.Get("Config"); var user = context.GuildUsers.First(n => n.Id == userId.ID); var cfg = await config.GetConfig(); @@ -192,9 +186,9 @@ public async Task ThenTheUserShouldBeGrantedMembership() [Then("the user should not be granted membership")] public async Task ThenTheUserShouldNotBeGrantedMembership() { - var userId = _scenarioContext.Get("UserID"); - var context = _scenarioContext.Get("Context"); - var config = _scenarioContext.Get("Config"); + var userId = scenarioContext.Get("UserID"); + var context = scenarioContext.Get("Context"); + var config = scenarioContext.Get("Config"); var user = context.GuildUsers.First(n => n.Id == userId.ID); var cfg = await config.GetConfig(); @@ -209,24 +203,24 @@ public void GivenNotBeenPunished() // ignore } - [Given(@"First joined (.*) hours ago")] - public void GivenFirstJoinedHoursAgo(int hoursAgo) => _scenarioContext.Add("UserFirstJoinedTime", hoursAgo); + [Given("First joined (.*) hours ago")] + public void GivenFirstJoinedHoursAgo(int hoursAgo) => scenarioContext.Add("UserFirstJoinedTime", hoursAgo); - [Given(@"Joined the server for the first time")] - public void GivenJoinedTheServerForTheFirstTime() => _scenarioContext.Add("UserFirstJoinedTime", 0); + [Given("Joined the server for the first time")] + public void GivenJoinedTheServerForTheFirstTime() => scenarioContext.Add("UserFirstJoinedTime", 0); - [Given(@"Been granted membership before")] - public void GivenBeenGrantedMembershipBefore() => _scenarioContext.Add("UserGrantedMembershipBefore", true); + [Given("Been granted membership before")] + public void GivenBeenGrantedMembershipBefore() => scenarioContext.Add("UserGrantedMembershipBefore", true); - [Given(@"Not been granted membership before")] - public void GivenNotBeenGrantedMembershipBefore() => _scenarioContext.Add("UserGrantedMembershipBefore", false); + [Given("Not been granted membership before")] + public void GivenNotBeenGrantedMembershipBefore() => scenarioContext.Add("UserGrantedMembershipBefore", false); - [When(@"the user joins the server")] + [When("the user joins the server")] public async Task WhenTheUserJoinsTheServer() { await SetupTest(); - var service = _scenarioContext.Get("DiscordService") as MockDiscordService; - var user = _scenarioContext.Get("User"); + var service = scenarioContext.Get("DiscordService") as MockDiscordService; + var user = scenarioContext.Get("User"); service?.TriggerUserJoined(user); } diff --git a/InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs index f77a20e..064ac00 100644 --- a/InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs +++ b/InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using FluentAssertions; using InstarBot.Tests.Services; using Microsoft.Extensions.DependencyInjection; @@ -11,15 +10,8 @@ namespace InstarBot.Tests.Integration; [Binding] -public class BirthdayCommandStepDefinitions +public class BirthdayCommandStepDefinitions(ScenarioContext context) { - private readonly ScenarioContext _context; - - public BirthdayCommandStepDefinitions(ScenarioContext context) - { - _context = context; - } - [Given("the user provides the following parameters")] public void GivenTheUserProvidesTheFollowingParameters(Table table) { @@ -28,21 +20,21 @@ public void GivenTheUserProvidesTheFollowingParameters(Table table) // Let's see if we have the bare minimum Assert.True(dict.ContainsKey("Year") && dict.ContainsKey("Month") && dict.ContainsKey("Day")); - _context.Add("Year", dict["Year"]); - _context.Add("Month", dict["Month"]); - _context.Add("Day", dict["Day"]); + context.Add("Year", dict["Year"]); + context.Add("Month", dict["Month"]); + context.Add("Day", dict["Day"]); if (dict.TryGetValue("Timezone", out var value)) - _context.Add("Timezone", value); + context.Add("Timezone", value); } [When("the user calls the Set Birthday command")] public async Task WhenTheUserCallsTheSetBirthdayCommand() { - var year = _context.Get("Year"); - var month = _context.Get("Month"); - var day = _context.Get("Day"); - var timezone = _context.ContainsKey("Timezone") ? _context.Get("Timezone") : 0; + var year = context.Get("Year"); + var month = context.Get("Month"); + var day = context.Get("Day"); + var timezone = context.ContainsKey("Timezone") ? context.Get("Timezone") : 0; var userId = new Snowflake().ID; @@ -51,19 +43,18 @@ public async Task WhenTheUserCallsTheSetBirthdayCommand() { UserID = userId }); - _context.Add("Command", cmd); - _context.Add("UserID", userId); - _context.Add("DDB", ddbService); + context.Add("Command", cmd); + context.Add("UserID", userId); + context.Add("DDB", ddbService); await cmd.Object.SetBirthday((Month)month, day, year, timezone); } [Then("DynamoDB should have the user's (Birthday|JoinDate) set to (.*)")] - [SuppressMessage("ReSharper", "SpecFlow.MethodNameMismatchPattern")] public async Task ThenDynamoDbShouldHaveBirthdaySetTo(string dataType, DateTime time) { - var ddbService = _context.Get("DDB"); - var userId = _context.Get("UserID"); + var ddbService = context.Get("DDB"); + var userId = context.Get("UserID"); switch (dataType) { @@ -74,7 +65,7 @@ public async Task ThenDynamoDbShouldHaveBirthdaySetTo(string dataType, DateTime (await ddbService!.GetUserJoinDate(userId)).Should().Be(time.ToUniversalTime()); break; default: - Assert.False(true, "Invalid test setup: dataType is unknown"); + Assert.Fail("Invalid test setup: dataType is unknown"); break; } } diff --git a/InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs index 52c0f90..23ca192 100644 --- a/InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs +++ b/InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs @@ -10,54 +10,47 @@ namespace InstarBot.Tests.Integration; [Binding] -public sealed class PageCommandStepDefinitions +public sealed class PageCommandStepDefinitions(ScenarioContext scenarioContext) { - private readonly ScenarioContext _scenarioContext; - - public PageCommandStepDefinitions(ScenarioContext scenarioContext) - { - _scenarioContext = scenarioContext; - } - [Given("the user is in team (.*)")] public async Task GivenTheUserIsInTeam(PageTarget target) { var team = await TestUtilities.GetTeams(target).FirstAsync(); - _scenarioContext.Add("UserTeamID", team.ID); + scenarioContext.Add("UserTeamID", team.ID); } [Given("the user is not a staff member")] public void GivenTheUserIsNotAStaffMember() { - _scenarioContext.Add("UserTeamID", new Snowflake()); + scenarioContext.Add("UserTeamID", new Snowflake()); } [Given("the user is paging (Helper|Moderator|Admin|Owner|Test|All)")] [SuppressMessage("ReSharper", "SpecFlow.MethodNameMismatchPattern")] public void GivenTheUserIsPaging(PageTarget target) { - _scenarioContext.Add("PageTarget", target); - _scenarioContext.Add("PagingTeamLeader", false); + scenarioContext.Add("PageTarget", target); + scenarioContext.Add("PagingTeamLeader", false); } [Given("the user is paging the (Helper|Moderator|Admin|Owner|Test|All) teamleader")] [SuppressMessage("ReSharper", "SpecFlow.MethodNameMismatchPattern")] public void GivenTheUserIsPagingTheTeamTeamleader(PageTarget target) { - _scenarioContext.Add("PageTarget", target); - _scenarioContext.Add("PagingTeamLeader", true); + scenarioContext.Add("PageTarget", target); + scenarioContext.Add("PagingTeamLeader", true); } [When("the user calls the Page command")] public async Task WhenTheUserCallsThePageCommand() { - _scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); - _scenarioContext.ContainsKey("PagingTeamLeader").Should().BeTrue(); - var pageTarget = _scenarioContext.Get("PageTarget"); - var pagingTeamLeader = _scenarioContext.Get("PagingTeamLeader"); + scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); + scenarioContext.ContainsKey("PagingTeamLeader").Should().BeTrue(); + var pageTarget = scenarioContext.Get("PageTarget"); + var pagingTeamLeader = scenarioContext.Get("PagingTeamLeader"); var command = SetupMocks(); - _scenarioContext.Add("Command", command); + scenarioContext.Add("Command", command); await command.Object.Page(pageTarget, "This is a test reason", pagingTeamLeader); } @@ -65,10 +58,10 @@ public async Task WhenTheUserCallsThePageCommand() [Then("Instar should emit a valid Page embed")] public async Task ThenInstarShouldEmitAValidPageEmbed() { - _scenarioContext.ContainsKey("Command").Should().BeTrue(); - _scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); - var command = _scenarioContext.Get>("Command"); - var pageTarget = _scenarioContext.Get("PageTarget"); + scenarioContext.ContainsKey("Command").Should().BeTrue(); + scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); + var command = scenarioContext.Get>("Command"); + var pageTarget = scenarioContext.Get("PageTarget"); string expectedString; @@ -86,23 +79,23 @@ public async Task ThenInstarShouldEmitAValidPageEmbed() "RespondAsync", Times.Once(), expectedString, ItExpr.IsNull(), false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny()); + ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); } [Then("Instar should emit a valid teamleader Page embed")] public async Task ThenInstarShouldEmitAValidTeamleaderPageEmbed() { - _scenarioContext.ContainsKey("Command").Should().BeTrue(); - _scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); + scenarioContext.ContainsKey("Command").Should().BeTrue(); + scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); - var command = _scenarioContext.Get>("Command"); - var pageTarget = _scenarioContext.Get("PageTarget"); + var command = scenarioContext.Get>("Command"); + var pageTarget = scenarioContext.Get("PageTarget"); command.Protected().Verify( "RespondAsync", Times.Once(), $"<@{await GetTeamLead(pageTarget)}>", ItExpr.IsNull(), false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny()); + ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); } private static async Task GetTeamLead(PageTarget pageTarget) @@ -120,8 +113,8 @@ private static async Task GetTeamLead(PageTarget pageTarget) [Then("Instar should emit a valid All Page embed")] public async Task ThenInstarShouldEmitAValidAllPageEmbed() { - _scenarioContext.ContainsKey("Command").Should().BeTrue(); - var command = _scenarioContext.Get>("Command"); + scenarioContext.ContainsKey("Command").Should().BeTrue(); + var command = scenarioContext.Get>("Command"); var expected = string.Join(' ', await TestUtilities.GetTeams(PageTarget.All).Select(n => Snowflake.GetMention(() => n.ID)).ToArrayAsync()); @@ -129,18 +122,18 @@ public async Task ThenInstarShouldEmitAValidAllPageEmbed() "RespondAsync", Times.Once(), expected, ItExpr.IsNull(), false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny()); + ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); } private Mock SetupMocks() { - var userTeam = _scenarioContext.Get("UserTeamID"); + var userTeam = scenarioContext.Get("UserTeamID"); var commandMock = TestUtilities.SetupCommandMock( () => new PageCommand(TestUtilities.GetTeamService(), new MockMetricService()), new TestContext { - UserRoles = new List { userTeam } + UserRoles = [userTeam] }); return commandMock; diff --git a/InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs index 1e76b23..ff96def 100644 --- a/InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs +++ b/InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs @@ -3,20 +3,13 @@ namespace InstarBot.Tests.Integration; [Binding] -public class PingCommandStepDefinitions +public class PingCommandStepDefinitions(ScenarioContext context) { - private readonly ScenarioContext _context; - - public PingCommandStepDefinitions(ScenarioContext context) - { - _context = context; - } - [When("the user calls the Ping command")] public async Task WhenTheUserCallsThePingCommand() { var command = TestUtilities.SetupCommandMock(); - _context.Add("Command", command); + context.Add("Command", command); await command.Object.Ping(); } diff --git a/InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs index c044cb5..ddb7a73 100644 --- a/InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs +++ b/InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs @@ -11,19 +11,12 @@ namespace InstarBot.Tests.Integration; [Binding] -public class ReportUserCommandStepDefinitions +public class ReportUserCommandStepDefinitions(ScenarioContext context) { - private readonly ScenarioContext _context; - - public ReportUserCommandStepDefinitions(ScenarioContext context) - { - _context = context; - } - [When("the user (.*) reports a message with the following properties")] public async Task WhenTheUserReportsAMessageWithTheFollowingProperties(ulong userId, Table table) { - _context.Add("ReportingUserID", userId); + context.Add("ReportingUserID", userId); var messageProperties = table.Rows.ToDictionary(n => n["Key"], n => n); @@ -31,13 +24,13 @@ public async Task WhenTheUserReportsAMessageWithTheFollowingProperties(ulong use Assert.True(messageProperties.ContainsKey("Sender")); Assert.True(messageProperties.ContainsKey("Channel")); - _context.Add("MessageContent", messageProperties["Content"].GetString("Value")); - _context.Add("MessageSender", (ulong)messageProperties["Sender"].GetInt64("Value")); - _context.Add("MessageChannel", (ulong)messageProperties["Channel"].GetInt64("Value")); + context.Add("MessageContent", messageProperties["Content"].GetString("Value")); + context.Add("MessageSender", (ulong)messageProperties["Sender"].GetInt64("Value")); + context.Add("MessageChannel", (ulong)messageProperties["Channel"].GetInt64("Value")); var (command, interactionContext) = SetupMocks(); - _context.Add("Command", command); - _context.Add("InteractionContext", interactionContext); + context.Add("Command", command); + context.Add("InteractionContext", interactionContext); await command.Object.HandleCommand(interactionContext.Object); } @@ -45,11 +38,11 @@ public async Task WhenTheUserReportsAMessageWithTheFollowingProperties(ulong use [When("does not complete the modal within 5 minutes")] public async Task WhenDoesNotCompleteTheModalWithinMinutes() { - Assert.True(_context.ContainsKey("Command")); - var command = _context.Get>("Command"); + Assert.True(context.ContainsKey("Command")); + var command = context.Get>("Command"); ReportUserCommand.PurgeCache(); - _context.Add("ReportReason", string.Empty); + context.Add("ReportReason", string.Empty); await command.Object.ModalResponse(new ReportMessageModal { @@ -60,9 +53,9 @@ await command.Object.ModalResponse(new ReportMessageModal [When(@"completes the report modal with reason ""(.*)""")] public async Task WhenCompletesTheReportModalWithReason(string reportReason) { - Assert.True(_context.ContainsKey("Command")); - var command = _context.Get>("Command"); - _context.Add("ReportReason", reportReason); + Assert.True(context.ContainsKey("Command")); + var command = context.Get>("Command"); + context.Add("ReportReason", reportReason); await command.Object.ModalResponse(new ReportMessageModal { @@ -73,17 +66,17 @@ await command.Object.ModalResponse(new ReportMessageModal [Then("Instar should emit a message report embed")] public void ThenInstarShouldEmitAMessageReportEmbed() { - Assert.True(_context.ContainsKey("TextChannelMock")); - var textChannel = _context.Get>("TextChannelMock"); + Assert.True(context.ContainsKey("TextChannelMock")); + var textChannel = context.Get>("TextChannelMock"); textChannel.Verify(n => n.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())); + It.IsAny(), It.IsAny(), It.IsAny())); - Assert.True(_context.ContainsKey("ResultEmbed")); - var embed = _context.Get("ResultEmbed"); + Assert.True(context.ContainsKey("ResultEmbed")); + var embed = context.Get("ResultEmbed"); embed.Author.Should().NotBeNull(); embed.Footer.Should().NotBeNull(); @@ -94,25 +87,25 @@ public void ThenInstarShouldEmitAMessageReportEmbed() { var commandMockContext = new TestContext { - UserID = _context.Get("ReportingUserID"), - EmbedCallback = embed => _context.Add("ResultEmbed", embed) + UserID = context.Get("ReportingUserID"), + EmbedCallback = embed => context.Add("ResultEmbed", embed) }; var commandMock = TestUtilities.SetupCommandMock (() => new ReportUserCommand(TestUtilities.GetDynamicConfiguration(), new MockMetricService()), commandMockContext); - _context.Add("TextChannelMock", commandMockContext.TextChannelMock); + context.Add("TextChannelMock", commandMockContext.TextChannelMock); return (commandMock, SetupMessageCommandMock()); } private Mock SetupMessageCommandMock() { - var userMock = TestUtilities.SetupUserMock(_context.Get("ReportingUserID")); - var authorMock = TestUtilities.SetupUserMock(_context.Get("MessageSender")); + var userMock = TestUtilities.SetupUserMock(context.Get("ReportingUserID")); + var authorMock = TestUtilities.SetupUserMock(context.Get("MessageSender")); - var channelMock = TestUtilities.SetupChannelMock(_context.Get("MessageChannel")); + var channelMock = TestUtilities.SetupChannelMock(context.Get("MessageChannel")); var messageMock = new Mock(); messageMock.Setup(n => n.Id).Returns(100); diff --git a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj index a65817c..51e7d64 100644 --- a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj +++ b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj @@ -1,7 +1,7 @@ - net6.0 + net9.0 enable false @@ -10,16 +10,16 @@ - - - - - - + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs b/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs index 489a2fd..8a0f55f 100644 --- a/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs +++ b/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Moq; -using PaxAndromeda.Instar; using PaxAndromeda.Instar.Preconditions; using Xunit; @@ -25,10 +24,7 @@ public async Task CheckRequirementsAsync_ShouldReturnFalse_WithBadConfig() var context = TestUtilities.SetupContext(new TestContext { - UserRoles = new List - { - new(793607635608928257) - } + UserRoles = [new(793607635608928257)] }); // Act @@ -62,10 +58,7 @@ public async Task CheckRequirementsAsync_ShouldReturnSuccessful_WithValidUser() var context = TestUtilities.SetupContext(new TestContext { - UserRoles = new List - { - new(793607635608928257) - } + UserRoles = [new(793607635608928257)] }); // Act @@ -83,10 +76,7 @@ public async Task CheckRequirementsAsync_ShouldReturnFailure_WithNonStaffUser() var context = TestUtilities.SetupContext(new TestContext { - UserRoles = new List - { - new() - } + UserRoles = [new()] }); // Act diff --git a/InstarBot.sln.DotSettings b/InstarBot.sln.DotSettings index 9339f36..48c34d6 100644 --- a/InstarBot.sln.DotSettings +++ b/InstarBot.sln.DotSettings @@ -7,4 +7,19 @@ DDB ID TTS - URL \ No newline at end of file + URL + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True \ No newline at end of file diff --git a/InstarBot/AsyncEvent.cs b/InstarBot/AsyncEvent.cs index 78b9a1e..5c6bc33 100644 --- a/InstarBot/AsyncEvent.cs +++ b/InstarBot/AsyncEvent.cs @@ -5,13 +5,8 @@ namespace PaxAndromeda.Instar; internal sealed class AsyncEvent { - private readonly object _subLock = new(); - private ImmutableArray> _subscriptions; - - public AsyncEvent() - { - _subscriptions = ImmutableArray.Create>(); - } + private readonly Lock _subLock = new(); + private ImmutableArray> _subscriptions = []; public async Task Invoke(T parameter) { diff --git a/InstarBot/Caching/MemoryCache.cs b/InstarBot/Caching/MemoryCache.cs index c58ffdb..f61b53a 100644 --- a/InstarBot/Caching/MemoryCache.cs +++ b/InstarBot/Caching/MemoryCache.cs @@ -15,22 +15,19 @@ public MemoryCache(string name, NameValueCollection config, bool ignoreConfigSec { } - [SuppressMessage("ReSharper", "UnusedMember.Global")] public bool Add(string key, T value, DateTimeOffset absoluteExpiration, string regionName = null!) { return value != null && base.Add(key, value, absoluteExpiration, regionName); } - [SuppressMessage("ReSharper", "UnusedMethodReturnValue.Global")] public bool Add(string key, T value, CacheItemPolicy policy, string regionName = null!) { return value != null && base.Add(key, value, policy, regionName); } - [SuppressMessage("ReSharper", "UnusedMember.Global")] public new T Get(string key, string regionName = null!) { - return (T) base.Get(key, regionName); + return (T) base.Get(key, regionName) ?? throw new InvalidOperationException($"{nameof(key)} cannot be null"); } public new IEnumerator> GetEnumerator() diff --git a/InstarBot/Commands/CheckEligibilityCommand.cs b/InstarBot/Commands/CheckEligibilityCommand.cs index 66d8c7f..b8d97f0 100644 --- a/InstarBot/Commands/CheckEligibilityCommand.cs +++ b/InstarBot/Commands/CheckEligibilityCommand.cs @@ -3,8 +3,6 @@ using Discord; using Discord.Interactions; using JetBrains.Annotations; -using Microsoft.Extensions.Configuration; -using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Serilog; @@ -12,25 +10,17 @@ namespace PaxAndromeda.Instar.Commands; [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class CheckEligibilityCommand : BaseCommand +public class CheckEligibilityCommand( + IDynamicConfigService dynamicConfig, + AutoMemberSystem autoMemberSystem, + IMetricService metricService) + : BaseCommand { - private readonly IDynamicConfigService _dynamicConfig; - private readonly AutoMemberSystem _autoMemberSystem; - private readonly IMetricService _metricService; - - public CheckEligibilityCommand(IDynamicConfigService dynamicConfig, AutoMemberSystem autoMemberSystem, - IMetricService metricService) - { - _dynamicConfig = dynamicConfig; - _autoMemberSystem = autoMemberSystem; - _metricService = metricService; - } - [UsedImplicitly] [SlashCommand("checkeligibility", "This command checks your membership eligibility.")] public async Task CheckEligibility() { - var config = await _dynamicConfig.GetConfig(); + var config = await dynamicConfig.GetConfig(); if (Context.User is null) { @@ -50,7 +40,7 @@ public async Task CheckEligibility() return; } - var eligibility = await _autoMemberSystem.CheckEligibility(Context.User); + var eligibility = await autoMemberSystem.CheckEligibility(Context.User); Log.Debug("Building response embed..."); var fields = new List(); @@ -83,12 +73,12 @@ public async Task CheckEligibility() Log.Debug("Responding..."); await RespondAsync(embed: builder.Build(), ephemeral: true); - await _metricService.Emit(Metric.AMS_EligibilityCheck, 1); + await metricService.Emit(Metric.AMS_EligibilityCheck, 1); } private async Task BuildMissingItemsText(MembershipEligibility eligibility, IGuildUser user) { - var config = await _dynamicConfig.GetConfig(); + var config = await dynamicConfig.GetConfig(); if (eligibility == MembershipEligibility.Eligible) return string.Empty; diff --git a/InstarBot/Commands/PageCommand.cs b/InstarBot/Commands/PageCommand.cs index d9f41ae..7818e81 100644 --- a/InstarBot/Commands/PageCommand.cs +++ b/InstarBot/Commands/PageCommand.cs @@ -14,17 +14,8 @@ namespace PaxAndromeda.Instar.Commands; // Required to be unsealed for mocking [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] [SuppressMessage("ReSharper", "ClassWithVirtualMembersNeverInherited.Global")] // Required for mocking -public class PageCommand : BaseCommand +public class PageCommand(TeamService teamService, IMetricService metricService) : BaseCommand { - private readonly TeamService _teamService; - private readonly IMetricService _metricService; - - public PageCommand(TeamService teamService, IMetricService metricService) - { - _teamService = teamService; - _metricService = metricService; - } - [UsedImplicitly] [SlashCommand("page", "This command initiates a directed page.")] [RequireStaffMember] @@ -51,7 +42,7 @@ public async Task Page( { Log.Verbose("User {User} is attempting to page {Team}: {Reason}", Context.User.Id, team, reason); - var userTeam = await _teamService.GetUserPrimaryStaffTeam(Context.User); + var userTeam = await teamService.GetUserPrimaryStaffTeam(Context.User); if (!CheckPermissions(Context.User, userTeam, team, teamLead, out var response)) { await RespondAsync(response, ephemeral: true); @@ -62,9 +53,9 @@ public async Task Page( if (team == PageTarget.Test) mention = "This is a __**TEST**__ page."; else if (teamLead) - mention = await _teamService.GetTeamLeadMention(team); + mention = await teamService.GetTeamLeadMention(team); else - mention = await _teamService.GetTeamMention(team); + mention = await teamService.GetTeamMention(team); Log.Debug("Emitting page to {ChannelName}", Context.Channel?.Name); await RespondAsync( @@ -72,7 +63,7 @@ await RespondAsync( embed: BuildEmbed(reason, message, user, channel, userTeam!, Context.User), allowedMentions: AllowedMentions.All); - await _metricService.Emit(Metric.Paging_SentPages, 1); + await metricService.Emit(Metric.Paging_SentPages, 1); } catch (Exception ex) { diff --git a/InstarBot/Commands/ReportUserCommand.cs b/InstarBot/Commands/ReportUserCommand.cs index 610b61b..79fcefa 100644 --- a/InstarBot/Commands/ReportUserCommand.cs +++ b/InstarBot/Commands/ReportUserCommand.cs @@ -11,10 +11,8 @@ namespace PaxAndromeda.Instar.Commands; // Required to be unsealed for mocking [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class ReportUserCommand : BaseCommand, IContextCommand +public class ReportUserCommand(IDynamicConfigService dynamicConfig, IMetricService metricService) : BaseCommand, IContextCommand { - private readonly IDynamicConfigService _dynamicConfig; - private readonly IMetricService _metricService; private const string ModalId = "respond_modal"; private static readonly MemoryCache Cache = new("User Report Cache"); @@ -25,12 +23,6 @@ internal static void PurgeCache() Cache.Remove(n.Key, CacheEntryRemovedReason.Removed); } - public ReportUserCommand(IDynamicConfigService dynamicConfig, IMetricService metricService) - { - _dynamicConfig = dynamicConfig; - _metricService = metricService; - } - [ExcludeFromCodeCoverage(Justification = "Constant used for mapping")] public string Name => "Report Message"; @@ -59,9 +51,9 @@ public MessageCommandProperties CreateCommand() [ModalInteraction(ModalId)] public async Task ModalResponse(ReportMessageModal modal) { - var message = (IMessage)Cache.Get(Context.User!.Id.ToString()); + var message = (IMessage?) Cache.Get(Context.User!.Id.ToString()); // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - if (message == null) + if (message is null) { await RespondAsync("Report expired. Please try again.", ephemeral: true); return; @@ -74,7 +66,7 @@ public async Task ModalResponse(ReportMessageModal modal) private async Task SendReportMessage(ReportMessageModal modal, IMessage message, IInstarGuild guild) { - var cfg = await _dynamicConfig.GetConfig(); + var cfg = await dynamicConfig.GetConfig(); var fields = new List { @@ -124,6 +116,6 @@ private async Task SendReportMessage(ReportMessageModal modal, IMessage message, Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel) .SendMessageAsync(staffPing, embed: builder.Build()); - await _metricService.Emit(Metric.ReportUser_ReportsSent, 1); + await metricService.Emit(Metric.ReportUser_ReportsSent, 1); } } \ No newline at end of file diff --git a/InstarBot/Commands/SetBirthdayCommand.cs b/InstarBot/Commands/SetBirthdayCommand.cs index a6d02ac..40ef1b6 100644 --- a/InstarBot/Commands/SetBirthdayCommand.cs +++ b/InstarBot/Commands/SetBirthdayCommand.cs @@ -11,17 +11,8 @@ namespace PaxAndromeda.Instar.Commands; // Required to be unsealed for mocking [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class SetBirthdayCommand : BaseCommand +public class SetBirthdayCommand(IInstarDDBService ddbService, IMetricService metricService) : BaseCommand { - private readonly IInstarDDBService _ddbService; - private readonly IMetricService _metricService; - - public SetBirthdayCommand(IInstarDDBService ddbService, IMetricService metricService) - { - _ddbService = ddbService; - _metricService = metricService; - } - [UsedImplicitly] [RequireOwner] [DefaultMemberPermissions(GuildPermission.Administrator)] @@ -69,7 +60,7 @@ await RespondAsync( dtUtc); // TODO: Notify staff? - var ok = await _ddbService.UpdateUserBirthday(new Snowflake(Context.User!.Id), dtUtc); + var ok = await ddbService.UpdateUserBirthday(new Snowflake(Context.User!.Id), dtUtc); if (ok) { @@ -78,7 +69,7 @@ await RespondAsync( dtLocal, dtUtc); await RespondAsync($"Your birthday was set to {dtLocal:D}.", ephemeral: true); - await _metricService.Emit(Metric.BS_BirthdaysSet, 1); + await metricService.Emit(Metric.BS_BirthdaysSet, 1); } else { @@ -96,34 +87,34 @@ await RespondAsync("Your birthday could not be set at this time. Please try aga public async Task HandleTimezoneAutocomplete() { Log.Debug("AUTOCOMPLETE"); - IEnumerable results = new[] - { - new AutocompleteResult("GMT-12 International Date Line West", -12), - new AutocompleteResult("GMT-11 Midway Island, Samoa", -11), - new AutocompleteResult("GMT-10 Hawaii", -10), - new AutocompleteResult("GMT-9 Alaska", -9), - new AutocompleteResult("GMT-8 Pacific Time (US and Canada); Tijuana", -8), - new AutocompleteResult("GMT-7 Mountain Time (US and Canada)", -7), - new AutocompleteResult("GMT-6 Central Time (US and Canada)", -6), - new AutocompleteResult("GMT-5 Eastern Time (US and Canada)", -5), - new AutocompleteResult("GMT-4 Atlantic Time (Canada)", -4), - new AutocompleteResult("GMT-3 Brasilia, Buenos Aires, Georgetown", -3), - new AutocompleteResult("GMT-2 Mid-Atlantic", -2), - new AutocompleteResult("GMT-1 Azores, Cape Verde Islands", -1), - new AutocompleteResult("GMT+0 Greenwich Mean Time: Dublin, Edinburgh, Lisbon, London", 0), - new AutocompleteResult("GMT+1 Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna", 1), - new AutocompleteResult("GMT+2 Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius", 2), - new AutocompleteResult("GMT+3 Moscow, St. Petersburg, Volgograd", 3), - new AutocompleteResult("GMT+4 Abu Dhabi, Muscat", 4), - new AutocompleteResult("GMT+5 Islamabad, Karachi, Tashkent", 5), - new AutocompleteResult("GMT+6 Astana, Dhaka", 6), - new AutocompleteResult("GMT+7 Bangkok, Hanoi, Jakarta", 7), - new AutocompleteResult("GMT+8 Beijing, Chongqing, Hong Kong SAR, Urumqi", 8), - new AutocompleteResult("GMT+9 Seoul, Osaka, Sapporo, Tokyo", 9), - new AutocompleteResult("GMT+10 Canberra, Melbourne, Sydney", 10), - new AutocompleteResult("GMT+11 Magadan, Solomon Islands, New Caledonia", 11), - new AutocompleteResult("GMT+12 Auckland, Wellington", 12) - }; + IEnumerable results = + [ + new("GMT-12 International Date Line West", -12), + new("GMT-11 Midway Island, Samoa", -11), + new("GMT-10 Hawaii", -10), + new("GMT-9 Alaska", -9), + new("GMT-8 Pacific Time (US and Canada); Tijuana", -8), + new("GMT-7 Mountain Time (US and Canada)", -7), + new("GMT-6 Central Time (US and Canada)", -6), + new("GMT-5 Eastern Time (US and Canada)", -5), + new("GMT-4 Atlantic Time (Canada)", -4), + new("GMT-3 Brasilia, Buenos Aires, Georgetown", -3), + new("GMT-2 Mid-Atlantic", -2), + new("GMT-1 Azores, Cape Verde Islands", -1), + new("GMT+0 Greenwich Mean Time: Dublin, Edinburgh, Lisbon, London", 0), + new("GMT+1 Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna", 1), + new("GMT+2 Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius", 2), + new("GMT+3 Moscow, St. Petersburg, Volgograd", 3), + new("GMT+4 Abu Dhabi, Muscat", 4), + new("GMT+5 Islamabad, Karachi, Tashkent", 5), + new("GMT+6 Astana, Dhaka", 6), + new("GMT+7 Bangkok, Hanoi, Jakarta", 7), + new("GMT+8 Beijing, Chongqing, Hong Kong SAR, Urumqi", 8), + new("GMT+9 Seoul, Osaka, Sapporo, Tokyo", 9), + new("GMT+10 Canberra, Melbourne, Sydney", 10), + new("GMT+11 Magadan, Solomon Islands, New Caledonia", 11), + new("GMT+12 Auckland, Wellington", 12) + ]; await (Context.Interaction as SocketAutocompleteInteraction)?.RespondAsync(results)!; } diff --git a/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs b/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs index 988599b..2adac4c 100644 --- a/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs +++ b/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs @@ -5,15 +5,8 @@ namespace PaxAndromeda.Instar.Commands; -public sealed class TriggerAutoMemberSystemCommand : BaseCommand +public sealed class TriggerAutoMemberSystemCommand(AutoMemberSystem ams) : BaseCommand { - private readonly AutoMemberSystem _ams; - - public TriggerAutoMemberSystemCommand(AutoMemberSystem ams) - { - _ams = ams; - } - [UsedImplicitly] [RequireOwner] [DefaultMemberPermissions(GuildPermission.Administrator)] @@ -23,6 +16,6 @@ public async Task RunAMS() await RespondAsync("Auto Member System is running!", ephemeral: true); // Run it asynchronously - await _ams.RunAsync(); + await ams.RunAsync(); } } \ No newline at end of file diff --git a/InstarBot/ConfigurationException.cs b/InstarBot/ConfigurationException.cs index 94b4a8b..0e09111 100644 --- a/InstarBot/ConfigurationException.cs +++ b/InstarBot/ConfigurationException.cs @@ -3,9 +3,4 @@ namespace PaxAndromeda.Instar; [ExcludeFromCodeCoverage] -public sealed class ConfigurationException : Exception -{ - public ConfigurationException(string message) : base(message) - { - } -} \ No newline at end of file +public sealed class ConfigurationException(string message) : Exception(message); \ No newline at end of file diff --git a/InstarBot/Gaius/Caselog.cs b/InstarBot/Gaius/Caselog.cs index c00e107..4fd01be 100644 --- a/InstarBot/Gaius/Caselog.cs +++ b/InstarBot/Gaius/Caselog.cs @@ -6,11 +6,11 @@ namespace PaxAndromeda.Instar.Gaius; [UsedImplicitly] public record Caselog { - [UsedImplicitly] public Snowflake UserID { get; set; } = default!; + [UsedImplicitly] public Snowflake UserID { get; set; } = null!; [UsedImplicitly] public CaselogType Type { get; set; } [UsedImplicitly] public string Time { get; set; } = null!; - [UsedImplicitly] public Snowflake ModID { get; set; } = default!; - [UsedImplicitly] public string Reason { get; set; } = default!; + [UsedImplicitly] public Snowflake ModID { get; set; } = null!; + [UsedImplicitly] public string Reason { get; set; } = null!; [JsonConverter(typeof(UnixMillisecondDateTimeConverter)), UsedImplicitly] public DateTime Date { get; set; } diff --git a/InstarBot/Gaius/Warning.cs b/InstarBot/Gaius/Warning.cs index 241ff93..f927022 100644 --- a/InstarBot/Gaius/Warning.cs +++ b/InstarBot/Gaius/Warning.cs @@ -7,10 +7,10 @@ namespace PaxAndromeda.Instar.Gaius; [UsedImplicitly] public record Warning { - [UsedImplicitly] public Snowflake GuildID { get; set; } = default!; + [UsedImplicitly] public Snowflake GuildID { get; set; } = null!; [UsedImplicitly] public int WarnID { get; set; } - [UsedImplicitly] public Snowflake UserID { get; set; } = default!; - [UsedImplicitly] public string Reason { get; set; } = default!; + [UsedImplicitly] public Snowflake UserID { get; set; } = null!; + [UsedImplicitly] public string Reason { get; set; } = null!; [JsonConverter(typeof(UnixDateTimeConverter)), UsedImplicitly] public DateTime WarnDate { get; set; } @@ -20,5 +20,5 @@ public record Warning [JsonConverter(typeof(UnixDateTimeConverter)), UsedImplicitly] public DateTime? PardonDate { get; set; } - [UsedImplicitly] public Snowflake ModID { get; set; } = default!; + [UsedImplicitly] public Snowflake ModID { get; set; } = null!; } \ No newline at end of file diff --git a/InstarBot/InstarBot.csproj b/InstarBot/InstarBot.csproj index b313ff0..761299a 100644 --- a/InstarBot/InstarBot.csproj +++ b/InstarBot/InstarBot.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net9.0 enable enable PaxAndromeda.Instar @@ -14,25 +14,25 @@ - - - - - + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/InstarBot/MessageProperties.cs b/InstarBot/MessageProperties.cs index 1b16234..b56a91f 100644 --- a/InstarBot/MessageProperties.cs +++ b/InstarBot/MessageProperties.cs @@ -5,16 +5,9 @@ namespace PaxAndromeda.Instar; [StructLayout(LayoutKind.Sequential)] [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] -public readonly struct MessageProperties +public readonly struct MessageProperties(ulong userId, ulong channelId, ulong guildId) { - public readonly ulong UserID; - public readonly ulong ChannelID; - public readonly ulong GuildID; - - public MessageProperties(ulong userId, ulong channelId, ulong guildId) - { - UserID = userId; - ChannelID = channelId; - GuildID = guildId; - } + public readonly ulong UserID = userId; + public readonly ulong ChannelID = channelId; + public readonly ulong GuildID = guildId; } \ No newline at end of file diff --git a/InstarBot/Metrics/MetricDimensionAttribute.cs b/InstarBot/Metrics/MetricDimensionAttribute.cs index bcc1e80..7de31d8 100644 --- a/InstarBot/Metrics/MetricDimensionAttribute.cs +++ b/InstarBot/Metrics/MetricDimensionAttribute.cs @@ -1,14 +1,8 @@ namespace PaxAndromeda.Instar.Metrics; [AttributeUsage(AttributeTargets.Field)] -public sealed class MetricDimensionAttribute : Attribute +public sealed class MetricDimensionAttribute(string name, string value) : Attribute { - public string Name { get; } - public string Value { get; } - - public MetricDimensionAttribute(string name, string value) - { - Name = name; - Value = value; - } + public string Name { get; } = name; + public string Value { get; } = value; } \ No newline at end of file diff --git a/InstarBot/Metrics/MetricNameAttribute.cs b/InstarBot/Metrics/MetricNameAttribute.cs index 07a060b..4a4414b 100644 --- a/InstarBot/Metrics/MetricNameAttribute.cs +++ b/InstarBot/Metrics/MetricNameAttribute.cs @@ -1,12 +1,7 @@ namespace PaxAndromeda.Instar.Metrics; [AttributeUsage(AttributeTargets.Field)] -public sealed class MetricNameAttribute : Attribute +public sealed class MetricNameAttribute(string name) : Attribute { - public string Name { get; } - - public MetricNameAttribute(string name) - { - Name = name; - } + public string Name { get; } = name; } \ No newline at end of file diff --git a/InstarBot/PageTarget.cs b/InstarBot/PageTarget.cs index 9a81eea..4b4d523 100644 --- a/InstarBot/PageTarget.cs +++ b/InstarBot/PageTarget.cs @@ -1,5 +1,4 @@ -using Discord.Interactions; -using JetBrains.Annotations; +using JetBrains.Annotations; namespace PaxAndromeda.Instar; diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index 5d8139a..0648c45 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -49,8 +49,15 @@ public static async Task Main(string[] args) private static async void StopSystem(object? sender, ConsoleCancelEventArgs e) { - await _services.GetRequiredService().Stop(); - _cts.Cancel(); + try + { + await _services.GetRequiredService().Stop(); + await _cts.CancelAsync(); + } + catch (Exception err) + { + Log.Fatal(err, "FATAL: Unhandled exception caught during shutdown"); + } } private static async Task RunAsync(IConfiguration config) diff --git a/InstarBot/Services/AWSDynamicConfigService.cs b/InstarBot/Services/AWSDynamicConfigService.cs index 9b8bdd7..c994d8f 100644 --- a/InstarBot/Services/AWSDynamicConfigService.cs +++ b/InstarBot/Services/AWSDynamicConfigService.cs @@ -111,7 +111,7 @@ private async Task Poll(bool bypass) }); _nextToken = result.NextPollConfigurationToken; - _nextPollTime = DateTime.UtcNow + TimeSpan.FromSeconds(result.NextPollIntervalInSeconds); + _nextPollTime = DateTime.UtcNow + TimeSpan.FromSeconds((double)(result.NextPollIntervalInSeconds ?? 60)); // Per the documentation, if VersionLabel is empty, then the client // has the most up-to-date configuration already stored. We can stop diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index 0d49e50..63d320a 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -150,10 +150,17 @@ private void StartTimer() private async void TimerElapsed(object? sender, ElapsedEventArgs e) { - // Ensure the timer's interval is exactly 1 hour - _timer.Interval = 60 * 60 * 1000; + try + { + // Ensure the timer's interval is exactly 1 hour + _timer.Interval = 60 * 60 * 1000; - await RunAsync(); + await RunAsync(); + } + catch + { + // ignore + } } public async Task RunAsync() @@ -163,7 +170,7 @@ public async Task RunAsync() await _metricService.Emit(Metric.AMS_Runs, 1); var cfg = await _dynamicConfig.GetConfig(); - // Caution: This is an extremely long running method! + // Caution: This is an extremely long-running method! Log.Information("Beginning auto member routine"); if (cfg.AutoMemberConfig.EnableGaiusCheck) @@ -195,7 +202,7 @@ public async Task RunAsync() foreach (var user in newMembers) { - // User has all of the qualifications, let's update their role + // User has all the qualifications, let's update their role try { await GrantMembership(cfg, user); @@ -226,7 +233,7 @@ private async Task WasUserGrantedMembershipBefore(Snowflake snowflake) if (grantedMembership is null) return false; - // Cache for 6 hour sliding window. If accessed, time is reset. + // Cache for 6-hour sliding window. If accessed, time is reset. _ddbCache.Add(snowflake.ID.ToString(), grantedMembership.Value, new CacheItemPolicy { SlidingExpiration = TimeSpan.FromHours(6) @@ -303,9 +310,7 @@ private Dictionary GetMessagesSent() foreach (var cacheEntry in _messageCache) { - if (!map.ContainsKey(cacheEntry.Value.UserID)) - map.Add(cacheEntry.Value.UserID, 1); - else + if (!map.TryAdd(cacheEntry.Value.UserID, 1)) map[cacheEntry.Value.UserID]++; } diff --git a/InstarBot/Services/CloudwatchMetricService.cs b/InstarBot/Services/CloudwatchMetricService.cs index 24f8fc6..724d61a 100644 --- a/InstarBot/Services/CloudwatchMetricService.cs +++ b/InstarBot/Services/CloudwatchMetricService.cs @@ -47,10 +47,7 @@ public async Task Emit(Metric metric, double value) var response = await _client.PutMetricDataAsync(new PutMetricDataRequest { Namespace = _metricNamespace, - MetricData = new List - { - datum - } + MetricData = [datum] }); return response.HttpStatusCode == HttpStatusCode.OK; diff --git a/InstarBot/Services/DiscordService.cs b/InstarBot/Services/DiscordService.cs index 49d3d59..8284b81 100644 --- a/InstarBot/Services/DiscordService.cs +++ b/InstarBot/Services/DiscordService.cs @@ -85,14 +85,14 @@ private async Task HandleMessageCommand(SocketMessageCommand arg) { Log.Information("Message command: {CommandName}", arg.CommandName); - if (!_contextCommands.ContainsKey(arg.CommandName)) + if (!_contextCommands.TryGetValue(arg.CommandName, out var command)) { Log.Warning("Received message command interaction for unknown command by name {CommandName}", arg.CommandName); return; } - await _contextCommands[arg.CommandName].HandleCommand(new MessageCommandInteractionWrapper(arg)); + await command.HandleCommand(new MessageCommandInteractionWrapper(arg)); } private async Task HandleInteraction(SocketInteraction arg) @@ -187,14 +187,14 @@ public async Task> GetAllUsers() try { var guild = _socketClient.GetGuild(_guild); - await _socketClient.DownloadUsersAsync(new[] { guild }); + await _socketClient.DownloadUsersAsync([guild]); return guild.Users; } catch (Exception ex) { Log.Error(ex, "Failed to download users for guild {GuildID}", _guild); - return Array.Empty(); + return []; } } diff --git a/InstarBot/Services/GaiusAPIService.cs b/InstarBot/Services/GaiusAPIService.cs index dc60f63..63236cc 100644 --- a/InstarBot/Services/GaiusAPIService.cs +++ b/InstarBot/Services/GaiusAPIService.cs @@ -6,25 +6,18 @@ namespace PaxAndromeda.Instar.Services; -public sealed class GaiusAPIService : IGaiusAPIService +public sealed class GaiusAPIService(IDynamicConfigService config) : IGaiusAPIService { // Used in release mode // ReSharper disable once NotAccessedField.Local - private readonly IDynamicConfigService _config; private const string BaseURL = "https://api.gaiusbot.me"; private const string WarningsBaseURL = BaseURL + "/warnings"; private const string CaselogsBaseURL = BaseURL + "/caselogs"; - private readonly HttpClient _client; + private readonly HttpClient _client = new(); private string _apiKey = null!; private bool _initialized; private readonly SemaphoreSlim _semaphore = new(1, 1); - - public GaiusAPIService(IDynamicConfigService config) - { - _config = config; - _client = new HttpClient(); - } private async Task Initialize() { @@ -37,7 +30,7 @@ private async Task Initialize() if (_initialized) return; - _apiKey = await _config.GetParameter("GaiusKey") ?? + _apiKey = await config.GetParameter("GaiusKey") ?? throw new ConfigurationException("Could not acquire Gaius API key"); await VerifyKey(); _initialized = true; @@ -50,7 +43,7 @@ private async Task Initialize() private async Task VerifyKey() { - var cfg = await _config.GetConfig(); + var cfg = await config.GetConfig(); var targetGuild = cfg.TargetGuild; var keyData = Encoding.UTF8.GetString(Convert.FromBase64String(_apiKey)); @@ -74,7 +67,7 @@ public async Task> GetAllWarnings() var response = await Get($"{WarningsBaseURL}/all"); var result = JsonConvert.DeserializeObject(response); - return result ?? Array.Empty(); + return result ?? []; } public async Task> GetAllCaselogs() @@ -92,7 +85,7 @@ public async Task> GetWarningsAfter(DateTime dt) var response = await Get($"{WarningsBaseURL}/after/{dt:O}"); var result = JsonConvert.DeserializeObject(response); - return result ?? Array.Empty(); + return result ?? []; } public async Task> GetCaselogsAfter(DateTime dt) diff --git a/InstarBot/Services/InstarDDBService.cs b/InstarBot/Services/InstarDDBService.cs index 6713ff7..4dacfdd 100644 --- a/InstarBot/Services/InstarDDBService.cs +++ b/InstarBot/Services/InstarDDBService.cs @@ -73,7 +73,7 @@ public async Task UpdateUserMembership(Snowflake snowflake, bool membershi private async Task UpdateUserData(Snowflake snowflake, DataType dataType, T data) where T : DynamoDBEntry { - var table = Table.LoadTable(_client, TableName); + var table = new TableBuilder(_client, TableName).Build(); var updateData = new Document(new Dictionary { @@ -92,7 +92,7 @@ private async Task UpdateUserData(Snowflake snowflake, DataType dataTyp private async Task GetUserData(Snowflake snowflake, DataType dataType) { - var table = Table.LoadTable(_client, TableName); + var table = new TableBuilder(_client, TableName).Build(); var scan = table.Query(new Primitive(snowflake.ID.ToString()), new QueryFilter()); var results = await scan.GetRemainingAsync(); diff --git a/InstarBot/Services/TeamService.cs b/InstarBot/Services/TeamService.cs index 6fecc27..e129e0b 100644 --- a/InstarBot/Services/TeamService.cs +++ b/InstarBot/Services/TeamService.cs @@ -7,46 +7,39 @@ namespace PaxAndromeda.Instar.Services; [SuppressMessage("ReSharper", "UnusedMember.Global")] [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] -public sealed class TeamService +public sealed class TeamService(IDynamicConfigService dynamicConfig) { - private readonly IDynamicConfigService _dynamicConfig; - - public TeamService(IDynamicConfigService dynamicConfig) - { - _dynamicConfig = dynamicConfig; - } - public async Task Exists(string teamRef) { - var cfg = await _dynamicConfig.GetConfig(); + var cfg = await dynamicConfig.GetConfig(); return cfg.Teams.Any(n => n.InternalID.Equals(teamRef, StringComparison.Ordinal)); } public async Task Exists(Snowflake snowflake) { - var cfg = await _dynamicConfig.GetConfig(); + var cfg = await dynamicConfig.GetConfig(); return cfg.Teams.Any(n => n.ID.ID == snowflake.ID); } public async Task Get(string teamRef) { - var cfg = await _dynamicConfig.GetConfig(); + var cfg = await dynamicConfig.GetConfig(); return cfg.Teams.First(n => n.InternalID.Equals(teamRef, StringComparison.Ordinal)); } public async Task Get(Snowflake snowflake) { - var cfg = await _dynamicConfig.GetConfig(); + var cfg = await dynamicConfig.GetConfig(); return cfg.Teams.First(n => n.ID.ID == snowflake.ID); } public async IAsyncEnumerable GetTeams(PageTarget pageTarget) { - var cfg = await _dynamicConfig.GetConfig(); + var cfg = await dynamicConfig.GetConfig(); var teamRefs = pageTarget.GetAttributesOfType()?.Select(n => n.InternalID) ?? new List(); diff --git a/InstarBot/Snowflake.cs b/InstarBot/Snowflake.cs index 43de93b..d5be95a 100644 --- a/InstarBot/Snowflake.cs +++ b/InstarBot/Snowflake.cs @@ -27,7 +27,7 @@ namespace PaxAndromeda.Instar; /// [TypeConverter(typeof(SnowflakeConverter))] [JsonConverter(typeof(JSnowflakeConverter))] -public sealed class Snowflake : IEquatable +public sealed record Snowflake { private static int _increment; diff --git a/InstarBot/SnowflakeTypeAttribute.cs b/InstarBot/SnowflakeTypeAttribute.cs index cc289b3..cab5c00 100644 --- a/InstarBot/SnowflakeTypeAttribute.cs +++ b/InstarBot/SnowflakeTypeAttribute.cs @@ -1,12 +1,7 @@ namespace PaxAndromeda.Instar; [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] -public sealed class SnowflakeTypeAttribute : Attribute +public sealed class SnowflakeTypeAttribute(SnowflakeType type) : Attribute { - public SnowflakeType Type { get; } - - public SnowflakeTypeAttribute(SnowflakeType type) - { - Type = type; - } + public SnowflakeType Type { get; } = type; } \ No newline at end of file diff --git a/InstarBot/TeamRefAttribute.cs b/InstarBot/TeamRefAttribute.cs index 33f3c84..f98c670 100644 --- a/InstarBot/TeamRefAttribute.cs +++ b/InstarBot/TeamRefAttribute.cs @@ -5,12 +5,7 @@ /// regardless of the team's snowflake. /// [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] -public sealed class TeamRefAttribute : Attribute +public sealed class TeamRefAttribute(string teamInternalId) : Attribute { - public TeamRefAttribute(string teamInternalId) - { - InternalID = teamInternalId; - } - - public string InternalID { get; } + public string InternalID { get; } = teamInternalId; } \ No newline at end of file diff --git a/InstarBot/Wrappers/MessageCommandInteractionWrapper.cs b/InstarBot/Wrappers/MessageCommandInteractionWrapper.cs index 4e563d3..f787be5 100644 --- a/InstarBot/Wrappers/MessageCommandInteractionWrapper.cs +++ b/InstarBot/Wrappers/MessageCommandInteractionWrapper.cs @@ -9,22 +9,15 @@ namespace PaxAndromeda.Instar.Wrappers; /// [ExcludeFromCodeCoverage(Justification = "Wrapper class")] [SuppressMessage("ReSharper", "ClassWithVirtualMembersNeverInherited.Global")] -public class MessageCommandInteractionWrapper : IInstarMessageCommandInteraction +public class MessageCommandInteractionWrapper(IMessageCommandInteraction interaction) : IInstarMessageCommandInteraction { - private readonly IMessageCommandInteraction _interaction; - - public MessageCommandInteractionWrapper(IMessageCommandInteraction interaction) - { - _interaction = interaction; - } - - public virtual ulong Id => _interaction.Id; - public virtual IUser User => _interaction.User; - public virtual IMessageCommandInteractionData Data => _interaction.Data; + public virtual ulong Id => interaction.Id; + public virtual IUser User => interaction.User; + public virtual IMessageCommandInteractionData Data => interaction.Data; public virtual Task RespondWithModalAsync(string customId, RequestOptions options = null!, Action modifyModal = null!) where T : class, IModal { - return _interaction.RespondWithModalAsync(customId, options, modifyModal); + return interaction.RespondWithModalAsync(customId, options, modifyModal); } } \ No newline at end of file diff --git a/InstarBot/Wrappers/SocketGuildWrapper.cs b/InstarBot/Wrappers/SocketGuildWrapper.cs index 13157fd..1dd7207 100644 --- a/InstarBot/Wrappers/SocketGuildWrapper.cs +++ b/InstarBot/Wrappers/SocketGuildWrapper.cs @@ -9,14 +9,9 @@ namespace PaxAndromeda.Instar.Wrappers; /// [ExcludeFromCodeCoverage(Justification = "Wrapper class")] [SuppressMessage("ReSharper", "ClassWithVirtualMembersNeverInherited.Global")] -public class SocketGuildWrapper : IInstarGuild +public class SocketGuildWrapper(SocketGuild guild) : IInstarGuild { - private readonly SocketGuild _guild; - - public SocketGuildWrapper(SocketGuild guild) - { - _guild = guild ?? throw new ArgumentNullException(nameof(guild)); - } + private readonly SocketGuild _guild = guild ?? throw new ArgumentNullException(nameof(guild)); public virtual ulong Id => _guild.Id; public IEnumerable TextChannels => _guild.TextChannels; From 465a2617a0c9e46332559608eb39ef900345a0b1 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Wed, 2 Jul 2025 12:35:24 -0700 Subject: [PATCH 13/53] Refactored InstarBot.Tests.Integration to move away from SpecFlow - Removed SpecFlow feature files, codehooks, step definitions and other metadata. - Added new xUnit based integration tests. - Added some minor validation updates to SetBirthdayCommand.cs - Refactored some code with new C# language features --- InstarBot.Tests.Common/TestContext.cs | 24 +- .../Features/AutoMemberSystem.feature | 158 ------ .../Features/PageCommand.feature | 53 -- .../Features/PingCommand.feature | 9 - .../Features/ReportUserCommand.feature | 25 - .../Features/SetBirthdayCommand.feature | 57 -- InstarBot.Tests.Integration/Hooks/Hook.cs | 23 - .../InstarBot.Tests.Integration.csproj | 8 +- .../Interactions/AutoMemberSystemTests.cs | 496 ++++++++++++++++++ .../Interactions/PageCommandTests.cs | 225 ++++++++ .../Interactions/PingCommandTests.cs | 26 + .../Interactions/ReportUserTests.cs | 180 +++++++ .../Interactions/SetBirthdayCommandTests.cs | 111 ++++ .../Steps/AutoMemberSystemStepDefinitions.cs | 227 -------- .../Steps/BirthdayCommandStepDefinitions.cs | 72 --- .../Steps/PageCommandStepDefinitions.cs | 141 ----- .../Steps/PingCommandStepDefinitions.cs | 16 - .../Steps/ReportUserCommandStepDefinitions.cs | 129 ----- InstarBot/Commands/SetBirthdayCommand.cs | 15 +- 19 files changed, 1062 insertions(+), 933 deletions(-) delete mode 100644 InstarBot.Tests.Integration/Features/AutoMemberSystem.feature delete mode 100644 InstarBot.Tests.Integration/Features/PageCommand.feature delete mode 100644 InstarBot.Tests.Integration/Features/PingCommand.feature delete mode 100644 InstarBot.Tests.Integration/Features/ReportUserCommand.feature delete mode 100644 InstarBot.Tests.Integration/Features/SetBirthdayCommand.feature delete mode 100644 InstarBot.Tests.Integration/Hooks/Hook.cs create mode 100644 InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs create mode 100644 InstarBot.Tests.Integration/Interactions/PageCommandTests.cs create mode 100644 InstarBot.Tests.Integration/Interactions/PingCommandTests.cs create mode 100644 InstarBot.Tests.Integration/Interactions/ReportUserTests.cs create mode 100644 InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs delete mode 100644 InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs delete mode 100644 InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs delete mode 100644 InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs delete mode 100644 InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs delete mode 100644 InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs diff --git a/InstarBot.Tests.Common/TestContext.cs b/InstarBot.Tests.Common/TestContext.cs index 387c2dc..7babfc7 100644 --- a/InstarBot.Tests.Common/TestContext.cs +++ b/InstarBot.Tests.Common/TestContext.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using Discord; using InstarBot.Tests.Models; using Moq; @@ -7,7 +6,6 @@ namespace InstarBot.Tests; -[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] public sealed class TestContext { public ulong UserID { get; init; } = 1420070400100; @@ -22,28 +20,26 @@ public sealed class TestContext public List GuildUsers { get; init; } = []; - public Dictionary Channels { get; init; } = new(); - public Dictionary Roles { get; init; } = new(); + public Dictionary Channels { get; init; } = []; + public Dictionary Roles { get; init; } = []; - public Dictionary> Warnings { get; init; } = new(); - public Dictionary> Caselogs { get; init; } = new(); + public Dictionary> Warnings { get; init; } = []; + public Dictionary> Caselogs { get; init; } = []; public bool InhibitGaius { get; set; } public void AddWarning(Snowflake userId, Warning warning) { - if (!Warnings.ContainsKey(userId)) - Warnings.Add(userId, [warning]); - else - Warnings[userId].Add(warning); + if (!Warnings.TryGetValue(userId, out var list)) + Warnings[userId] = list = []; + list.Add(warning); } public void AddCaselog(Snowflake userId, Caselog caselog) { - if (!Caselogs.ContainsKey(userId)) - Caselogs.Add(userId, [caselog]); - else - Caselogs[userId].Add(caselog); + if (!Caselogs.TryGetValue(userId, out var list)) + Caselogs[userId] = list = []; + list.Add(caselog); } public void AddChannel(Snowflake channelId) diff --git a/InstarBot.Tests.Integration/Features/AutoMemberSystem.feature b/InstarBot.Tests.Integration/Features/AutoMemberSystem.feature deleted file mode 100644 index 11c0f80..0000000 --- a/InstarBot.Tests.Integration/Features/AutoMemberSystem.feature +++ /dev/null @@ -1,158 +0,0 @@ -@systems -Feature: Auto Member System - - The Auto Member System evaluates all new members on the server - and determines their eligibility for membership through a series - of checks. - - Background: - Given the roles as follows: - | Role ID | Role Name | - | 796052052433698817 | New Member | - | 793611808372031499 | Member | - | 796085775199502357 | Transfemme | - | 796148869855576064 | 21+ | - | 796578609535647765 | She/Her | - | 966434762032054282 | AMH | - - Rule: Eligible users should be granted membership. - Scenario: A user eligible for membership should be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme, 21+ and She/Her - * Posted an introduction - * Posted 100 messages in the past day - * Not been punished - * Joined the server for the first time - * Not been granted membership before - When the Auto Member System processes - Then the user should be granted membership - - Rule: Eligible users should not be granted membership if their membership is withheld. - Scenario: A user eligible for membership should not be granted membership if their membership is withheld. - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme, 21+, She/Her and AMH - * Posted an introduction - * Posted 100 messages in the past day - * Not been punished - * Joined the server for the first time - * Not been granted membership before - When the Auto Member System processes - Then the user should not be granted membership - - Rule: New or inactive users should not be granted membership. - Scenario: A user who joined the server less than the minimum time should not be granted membership - Given a user that has: - * Joined 12 hours ago - * The roles New Member, Transfemme, 21+ and She/Her - * Posted an introduction - * Posted 100 messages in the past day - * Not been punished - * Joined the server for the first time - * Not been granted membership before - When the Auto Member System processes - Then the user should not be granted membership - - Scenario: An inactive user should not be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme, 21+ and She/Her - * Posted an introduction - * Posted 10 messages in the past day - * Not been punished - When the Auto Member System processes - Then the user should not be granted membership - - Rule: Auto Member System should not affect Members. - Scenario: A user who is already a member should not be affected by the auto member system - Given a user that has: - * Joined 36 hours ago - * The roles Member, Transfemme, 21+ and She/Her - * Posted an introduction - * Posted 100 messages in the past day - * Not been punished - * Joined the server for the first time - * Not been granted membership before - When the Auto Member System processes - Then the user should remain unchanged - - Rule: Users should have all minimum requirements for membership - Scenario: A user that did not post an introduction should not be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme, 21+ and She/Her - * Did not post an introduction - * Posted 100 messages in the past day - * Not been punished - * Joined the server for the first time - * Not been granted membership before - When the Auto Member System processes - Then the user should not be granted membership - - Scenario: A user without an age role should not be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme and She/Her - * Posted an introduction - * Posted 100 messages in the past day - * Not been punished - When the Auto Member System processes - Then the user should not be granted membership - - Scenario: A user without a gender role should not be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, 21+ and She/Her - * Posted an introduction - * Posted 100 messages in the past day - * Not been punished - When the Auto Member System processes - Then the user should not be granted membership - - Scenario: A user without a pronoun role should not be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme, and 21+ - * Posted an introduction - * Posted 100 messages in the past day - * Not been punished - When the Auto Member System processes - Then the user should not be granted membership - - Rule: Gaius should be checked for warnings and caselogs - Scenario: A user with a warning should not be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme, 21+ and She/Her - * Posted an introduction - * Posted 100 messages in the past day - * Been issued a warning - * Joined the server for the first time - * Not been granted membership before - When the Auto Member System processes - Then the user should not be granted membership - - Scenario: A user with a caselog should not be granted membership - Given a user that has: - * Joined 36 hours ago - * The roles New Member, Transfemme, 21+ and She/Her - * Posted an introduction - * Posted 100 messages in the past day - * Been issued a mute - When the Auto Member System processes - Then the user should not be granted membership - - Rule: Users who have been granted membership and left should be granted membership upon rejoining - - Scenario: A user who has had membership before should be automatically granted membership - Given a user that has: - * Joined 1 hours ago - * Been granted membership before - * First joined 240 hours ago - * The roles New Member, Transfemme, 21+ and She/Her - * Posted an introduction - * Posted 2 messages in the past day - * Not been punished - When the user joins the server - Then the user should be granted membership \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Features/PageCommand.feature b/InstarBot.Tests.Integration/Features/PageCommand.feature deleted file mode 100644 index 4b609de..0000000 --- a/InstarBot.Tests.Integration/Features/PageCommand.feature +++ /dev/null @@ -1,53 +0,0 @@ -@interactions -@staff -Feature: Page Command - - The Page command is a staff-only utility to page specific - staff teams without needing to allow pinging of the team's - role to everybody. - - The command also permits several contextual parameters - that allow responding staff members to obtain context on - a situation quickly and efficiently. - - Scenario: User should be able to page when authorized - Given the user is in team Owner - And the user is paging Moderator - When the user calls the Page command - Then Instar should emit a valid Page embed - - Scenario: User should be able to page a team's teamleader - Given the user is in team Helper - And the user is paging the Owner teamleader - When the user calls the Page command - Then Instar should emit a valid teamleader Page embed - - Scenario: Any staff member should be able to use the Test page - Given the user is in team Helper - And the user is paging Test - When the user calls the Page command - Then Instar should emit a valid Page embed - - Scenario: Owner should be able to page all - Given the user is in team Owner - And the user is paging All - When the user calls the Page command - Then Instar should emit a valid All Page embed - - Scenario: Fail page if paging all teamleader - Given the user is in team Owner - And the user is paging the All teamleader - When the user calls the Page command - Then Instar should emit an ephemeral message stating "Failed to send page. The 'All' team does not have a teamleader. If intended to page the owner, please select the Owner as the team." - - Scenario: Unauthorized user should receive an error message - Given the user is not a staff member - And the user is paging Moderator - When the user calls the Page command - Then Instar should emit an ephemeral message stating "You are not authorized to use this command." - - Scenario: Helper should not be able to page all - Given the user is in team Helper - And the user is paging All - When the user calls the Page command - Then Instar should emit an ephemeral message stating "You are not authorized to send a page to the entire staff team." \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Features/PingCommand.feature b/InstarBot.Tests.Integration/Features/PingCommand.feature deleted file mode 100644 index 4bf8b4e..0000000 --- a/InstarBot.Tests.Integration/Features/PingCommand.feature +++ /dev/null @@ -1,9 +0,0 @@ -@interactions -Feature: Ping Command - - The Ping command is a simple mechanism to test - the responsiveness of the Instar bot. - - Scenario: User should be able to issue the Ping command. - When the user calls the Ping command - Then Instar should emit an ephemeral message stating "Pong!" \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Features/ReportUserCommand.feature b/InstarBot.Tests.Integration/Features/ReportUserCommand.feature deleted file mode 100644 index 62801ec..0000000 --- a/InstarBot.Tests.Integration/Features/ReportUserCommand.feature +++ /dev/null @@ -1,25 +0,0 @@ -@interactions -Feature: Report User Command - - The Report User interaction allows users to report - users and messages quietly without alerting the - reported user. - - Scenario: User should be able to report a message normally - When the user 1024 reports a message with the following properties - | Key | Value | - | Content | "This is a test message" | - | Sender | 128 | - | Channel | 256 | - And completes the report modal with reason "This is a test report" - Then Instar should emit an ephemeral message stating "Your report has been sent." - And Instar should emit a message report embed - - Scenario: Report user function times out if cache expires - When the user 1024 reports a message with the following properties - | Key | Value | - | Content | "This is a test message" | - | Sender | 128 | - | Channel | 256 | - And does not complete the modal within 5 minutes - Then Instar should emit an ephemeral message stating "Report expired. Please try again." \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Features/SetBirthdayCommand.feature b/InstarBot.Tests.Integration/Features/SetBirthdayCommand.feature deleted file mode 100644 index 3fb8ebe..0000000 --- a/InstarBot.Tests.Integration/Features/SetBirthdayCommand.feature +++ /dev/null @@ -1,57 +0,0 @@ -@interactions -Feature: Set Birthday Command - - The Set Birthday command allows users to set - their own birthdays, which is used within the - Birthday system. - - Scenario: User should be able to set a valid birthday - Given the user provides the following parameters - | Key | Value | - | Year | 1992 | - | Month | 7 | - | Day | 21 | - When the user calls the Set Birthday command - Then Instar should emit an ephemeral message stating "Your birthday was set to Tuesday, July 21, 1992." - And DynamoDB should have the user's Birthday set to 1992-07-21T00:00:00-00:00 - - # Note: Update this in 13 years - - Scenario: Underage user should be able to set a valid birthday - Given the user provides the following parameters - | Key | Value | - | Year | 2022 | - | Month | 7 | - | Day | 21 | - When the user calls the Set Birthday command - Then Instar should emit an ephemeral message stating "Your birthday was set to Thursday, July 21, 2022." - And DynamoDB should have the user's Birthday set to 2022-07-21T00:00:00-00:00 - - Scenario: User should be able to set a valid birthday with time zones - Given the user provides the following parameters - | Key | Value | - | Year | 1992 | - | Month | 7 | - | Day | 21 | - | Timezone | -8 | - When the user calls the Set Birthday command - Then Instar should emit an ephemeral message stating "Your birthday was set to Tuesday, July 21, 1992." - And DynamoDB should have the user's Birthday set to 1992-07-21T00:00:00-08:00 - - Scenario: Attempting to set an illegal day number should emit an error message. - Given the user provides the following parameters - | Key | Value | - | Year | 1992 | - | Month | 2 | - | Day | 31 | - When the user calls the Set Birthday command - Then Instar should emit an ephemeral message stating "There are only 29 days in February 1992. Your birthday was not set." - - Scenario: Attempting to set a birthday in the future should emit an error message - Given the user provides the following parameters - | Key | Value | - | Year | 9992 | - | Month | 2 | - | Day | 21 | - When the user calls the Set Birthday command - Then Instar should emit an ephemeral message stating "You are not a time traveler. Your birthday was not set." \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Hooks/Hook.cs b/InstarBot.Tests.Integration/Hooks/Hook.cs deleted file mode 100644 index 7cbb8d2..0000000 --- a/InstarBot.Tests.Integration/Hooks/Hook.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Xunit; - -namespace InstarBot.Tests.Integration.Hooks; - -[Binding] -public class InstarHooks(ScenarioContext scenarioContext) -{ - [Then(@"Instar should emit a message stating ""(.*)""")] - public void ThenInstarShouldEmitAMessageStating(string message) - { - Assert.True(scenarioContext.ContainsKey("Command")); - var cmdObject = scenarioContext.Get("Command"); - TestUtilities.VerifyMessage(cmdObject, message); - } - - [Then(@"Instar should emit an ephemeral message stating ""(.*)""")] - public void ThenInstarShouldEmitAnEphemeralMessageStating(string message) - { - Assert.True(scenarioContext.ContainsKey("Command")); - var cmdObject = scenarioContext.Get("Command"); - TestUtilities.VerifyMessage(cmdObject, message, true); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj index 4f762d8..157a6ad 100644 --- a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj +++ b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -6,16 +6,10 @@ enable - - - - - - all diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs new file mode 100644 index 0000000..72d6663 --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs @@ -0,0 +1,496 @@ +using Discord; +using FluentAssertions; +using InstarBot.Tests.Models; +using InstarBot.Tests.Services; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.Gaius; +using PaxAndromeda.Instar.Services; +using Xunit; + +namespace InstarBot.Tests.Integration.Interactions; + +public class AutoMemberSystemTests +{ + private static readonly Snowflake NewMember = new(796052052433698817); + private static readonly Snowflake Member = new(793611808372031499); + private static readonly Snowflake Transfemme = new(796085775199502357); + private static readonly Snowflake TwentyOnePlus = new(796148869855576064); + private static readonly Snowflake SheHer = new(796578609535647765); + private static readonly Snowflake AutoMemberHold = new(966434762032054282); + + private static async Task SetupTest(AutoMemberSystemContext scenarioContext) + { + var testContext = scenarioContext.TestContext; + + var discordService = TestUtilities.SetupDiscordService(testContext); + var gaiusApiService = TestUtilities.SetupGaiusAPIService(testContext); + var config = TestUtilities.GetDynamicConfiguration(); + + scenarioContext.DiscordService = discordService; + var userId = scenarioContext.UserID; + var relativeJoinTime = scenarioContext.HoursSinceJoined; + var roles = scenarioContext.Roles; + var postedIntro = scenarioContext.PostedIntroduction; + var messagesLast24Hours = scenarioContext.MessagesLast24Hours; + var firstSeenTime = scenarioContext.FirstJoinTime; + var grantedMembershipBefore = scenarioContext.GrantedMembershipBefore; + + var amsConfig = scenarioContext.Config.AutoMemberConfig; + + var ddbService = new MockInstarDDBService(); + if (firstSeenTime > 0) + await ddbService.UpdateUserJoinDate(userId, DateTime.UtcNow - TimeSpan.FromHours(firstSeenTime)); + if (grantedMembershipBefore) + await ddbService.UpdateUserMembership(userId, grantedMembershipBefore); + + testContext.AddRoles(roles); + + var user = new TestGuildUser + { + Id = userId, + JoinedAt = DateTimeOffset.Now - TimeSpan.FromHours(relativeJoinTime), + RoleIds = roles.Select(n => n.ID).ToList().AsReadOnly() + }; + + testContext.GuildUsers.Add(user); + + + var genericChannel = Snowflake.Generate(); + testContext.AddChannel(amsConfig.IntroductionChannel); + + testContext.AddChannel(genericChannel); + if (postedIntro) + testContext.GetChannel(amsConfig.IntroductionChannel).AddMessage(user, "Some text"); + + for (var i = 0; i < messagesLast24Hours; i++) + testContext.GetChannel(genericChannel).AddMessage(user, "Some text"); + + + var ams = new AutoMemberSystem(config, discordService, gaiusApiService, ddbService, new MockMetricService()); + + scenarioContext.User = user; + + return ams; + } + + + [Fact(DisplayName = "Eligible users should be granted membership")] + public static async Task AutoMemberSystem_EligibleUser_ShouldBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertMember(); + } + + + [Fact(DisplayName = "Eligible users should not be granted membership if their membership is withheld.")] + public static async Task AutoMemberSystem_EligibleUserWithAMH_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer, AutoMemberHold) + .HasPostedIntroduction() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + + [Fact(DisplayName = "New users should not be granted membership.")] + public static async Task AutoMemberSystem_NewUser_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(12)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "Inactive users should not be granted membership.")] + public static async Task AutoMemberSystem_InactiveUser_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .WithMessages(10) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "Auto Member System should not affect Members.")] + public static async Task AutoMemberSystem_Member_ShouldNotBeChanged() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(Member, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertUserUnchanged(); + } + + [Fact(DisplayName = "A user that did not post an introduction should not be granted membership")] + public static async Task AutoMemberSystem_NoIntroduction_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "A user without an age role should not be granted membership")] + public static async Task AutoMemberSystem_NoAgeRole_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, SheHer) + .HasPostedIntroduction() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "A user without a gender role should not be granted membership")] + public static async Task AutoMemberSystem_NoGenderRole_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "A user without a pronoun role should not be granted membership")] + public static async Task AutoMemberSystem_NoPronounRole_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus) + .HasPostedIntroduction() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "A user with a warning should not be granted membership")] + public static async Task AutoMemberSystem_UserWithGaiusWarning_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .HasBeenWarned() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "A user with a caselog should not be granted membership")] + public static async Task AutoMemberSystem_UserWithGaiusCaselog_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .HasBeenPunished() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "A user should be granted membership if Gaius is unavailable")] + public static async Task AutoMemberSystem_GaiusIsUnavailable_ShouldBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .InhibitGaius() + .WithMessages(100) + .Build(); + + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertMember(); + } + + [Fact(DisplayName = "A user should be granted membership if they have been granted membership before")] + public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(1)) + .FirstJoined(TimeSpan.FromDays(7)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .HasBeenGrantedMembershipBefore() + .WithMessages(100) + .Build(); + + await SetupTest(context); + + // Act + var service = context.DiscordService as MockDiscordService; + var user = context.User; + + service.Should().NotBeNull(); + user.Should().NotBeNull(); + + await service.TriggerUserJoined(user); + + // Assert + context.AssertMember(); + } + + private record AutoMemberSystemContext( + Snowflake UserID, + int HoursSinceJoined, + Snowflake[] Roles, + bool PostedIntroduction, + int MessagesLast24Hours, + int FirstJoinTime, + bool GrantedMembershipBefore, + TestContext TestContext, + InstarDynamicConfiguration Config) + { + public static AutoMemberSystemContextBuilder Builder() => new(); + + public IDiscordService? DiscordService { get; set; } + public IGuildUser User { get; set; } = null!; + + public void AssertMember() + { + User.Should().NotBeNull(); + User.RoleIds.Should().Contain(Member.ID); + User.RoleIds.Should().NotContain(NewMember.ID); + } + + public void AssertNotMember() + { + User.Should().NotBeNull(); + User.RoleIds.Should().NotContain(Member.ID); + User.RoleIds.Should().Contain(NewMember.ID); + } + + public void AssertUserUnchanged() + { + var testUser = User as TestGuildUser; + testUser.Should().NotBeNull(); + testUser.Changed.Should().BeFalse(); + } + } + + private class AutoMemberSystemContextBuilder + { + private int _hoursSinceJoined; + private Snowflake[]? _roles; + private bool _postedIntroduction; + private int _messagesLast24Hours; + private bool _gaiusAvailable = true; + private bool _gaiusPunished; + private bool _gaiusWarned; + private int _firstJoinTime; + private bool _grantedMembershipBefore; + + public AutoMemberSystemContextBuilder Joined(TimeSpan timeAgo) + { + _hoursSinceJoined = (int) Math.Round(timeAgo.TotalHours); + return this; + } + public AutoMemberSystemContextBuilder SetRoles(params Snowflake[] roles) + { + _roles = roles; + return this; + } + + public AutoMemberSystemContextBuilder HasPostedIntroduction() + { + _postedIntroduction = true; + return this; + } + + public AutoMemberSystemContextBuilder WithMessages(int messages) + { + _messagesLast24Hours = messages; + return this; + } + + public AutoMemberSystemContextBuilder InhibitGaius() + { + _gaiusAvailable = false; + return this; + } + + public AutoMemberSystemContextBuilder HasBeenPunished() + { + _gaiusPunished = true; + return this; + } + + public AutoMemberSystemContextBuilder HasBeenWarned() + { + _gaiusWarned = true; + return this; + } + + public AutoMemberSystemContextBuilder FirstJoined(TimeSpan hoursAgo) + { + _firstJoinTime = (int) Math.Round(hoursAgo.TotalHours); + return this; + } + + public AutoMemberSystemContextBuilder HasBeenGrantedMembershipBefore() + { + _grantedMembershipBefore = true; + return this; + } + + public async Task Build() + { + var config = await TestUtilities.GetDynamicConfiguration().GetConfig(); + + var testContext = new TestContext(); + + var userId = Snowflake.Generate(); + + // Set up any warnings or whatnot + testContext.InhibitGaius = !_gaiusAvailable; + + if (_gaiusPunished) + { + testContext.AddCaselog(userId, new Caselog + { + Type = CaselogType.Mute, + Reason = "TEST PUNISHMENT", + ModID = Snowflake.Generate(), + UserID = userId, + }); + } + if (_gaiusWarned) + { + testContext.AddWarning(userId, new Warning() + { + Reason = "TEST PUNISHMENT", + ModID = Snowflake.Generate(), + UserID = userId, + }); + } + + return new AutoMemberSystemContext( + userId, + _hoursSinceJoined, + _roles ?? throw new InvalidOperationException("Roles must be set."), + _postedIntroduction, + _messagesLast24Hours, + _firstJoinTime, + _grantedMembershipBefore, + testContext, + config); + } + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs new file mode 100644 index 0000000..c56d268 --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -0,0 +1,225 @@ +using Discord; +using InstarBot.Tests.Services; +using Moq; +using Moq.Protected; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using Xunit; + +namespace InstarBot.Tests.Integration.Interactions; + +public class PageCommandTests +{ + private static async Task> SetupCommandMock(PageCommandTestContext context) + { + // Treat the Test page target as a regular non-staff user on the server + var userTeam = context.UserTeamID == PageTarget.Test + ? Snowflake.Generate() + : (await TestUtilities.GetTeams(context.UserTeamID).FirstAsync()).ID; + + var commandMock = TestUtilities.SetupCommandMock( + () => new PageCommand(TestUtilities.GetTeamService(), new MockMetricService()), + new TestContext + { + UserRoles = [userTeam] + }); + + return commandMock; + } + + private static async Task GetTeamLead(PageTarget pageTarget) + { + var dynamicConfig = await TestUtilities.GetDynamicConfiguration().GetConfig(); + + var teamsConfig = + dynamicConfig.Teams.ToDictionary(n => n.InternalID, n => n); + + // Eeeeeeeeeeeeevil + return teamsConfig[pageTarget.GetAttributesOfType()?.First().InternalID ?? "idkjustfail"] + .Teamleader; + } + + private static async Task VerifyPageEmbedEmitted(Mock command, PageCommandTestContext context) + { + var pageTarget = context.PageTarget; + + string expectedString; + + if (context.PagingTeamLeader) + expectedString = $"<@{await GetTeamLead(pageTarget)}>"; + else + switch (pageTarget) + { + case PageTarget.All: + expectedString = string.Join(' ', + await TestUtilities.GetTeams(PageTarget.All).Select(n => Snowflake.GetMention(() => n.ID)) + .ToArrayAsync()); + break; + case PageTarget.Test: + expectedString = "This is a __**TEST**__ page."; + break; + default: + var team = await TestUtilities.GetTeams(pageTarget).FirstAsync(); + expectedString = Snowflake.GetMention(() => team.ID); + break; + } + + command.Protected().Verify( + "RespondAsync", Times.Once(), + expectedString, ItExpr.IsNull(), + false, false, AllowedMentions.All, ItExpr.IsNull(), + ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); + } + + [Fact(DisplayName = "User should be able to page when authorized")] + public static async Task PageCommand_Authorized_WhenPagingTeam_ShouldPageCorrectly() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Owner, + PageTarget.Moderator, + false + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + + // Assert + await VerifyPageEmbedEmitted(command, context); + } + + [Fact(DisplayName = "User should be able to page a team's teamleader")] + public static async Task PageCommand_Authorized_WhenPagingTeamLeader_ShouldPageCorrectly() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Owner, + PageTarget.Moderator, + true + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + + // Assert + await VerifyPageEmbedEmitted(command, context); + } + + [Fact(DisplayName = "Any staff member should be able to use the Test page")] + public static async Task PageCommand_AnyStaffMember_WhenPagingTest_ShouldPageCorrectly() + { + var targets = Enum.GetValues().Except([PageTarget.All, PageTarget.Test]); + + foreach (var userTeam in targets) + { + // Arrange + var context = new PageCommandTestContext( + userTeam, + PageTarget.Test, + false + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, + string.Empty); + + // Assert + await VerifyPageEmbedEmitted(command, context); + } + } + + [Fact(DisplayName = "Owner should be able to page all")] + public static async Task PageCommand_Authorized_WhenOwnerPagingAll_ShouldPageCorrectly() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Owner, + PageTarget.All, + false + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + + // Assert + await VerifyPageEmbedEmitted(command, context); + } + + [Fact(DisplayName = "Fail page if paging all teamleader")] + public static async Task PageCommand_Authorized_WhenOwnerPagingAllTeamLead_ShouldFail() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Owner, + PageTarget.All, + true + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + + // Assert + TestUtilities.VerifyMessage( + command, + "Failed to send page. The 'All' team does not have a teamleader. If intended to page the owner, please select the Owner as the team.", + true + ); + } + + [Fact(DisplayName = "Unauthorized user should receive an error message")] + public static async Task PageCommand_Unauthorized_WhenAttemptingToPage_ShouldFail() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Test, + PageTarget.Moderator, + false + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + + // Assert + TestUtilities.VerifyMessage( + command, + "You are not authorized to use this command.", + true + ); + } + + [Fact(DisplayName = "Helper should not be able to page all")] + public static async Task PageCommand_Authorized_WhenHelperAttemptsToPageAll_ShouldFail() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Helper, + PageTarget.All, + false + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + + // Assert + TestUtilities.VerifyMessage( + command, + "You are not authorized to send a page to the entire staff team.", + true + ); + } + private record PageCommandTestContext(PageTarget UserTeamID, PageTarget PageTarget, bool PagingTeamLeader); +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs new file mode 100644 index 0000000..8ea3d75 --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs @@ -0,0 +1,26 @@ +using PaxAndromeda.Instar.Commands; + +namespace InstarBot.Tests.Integration.Interactions; +using Xunit; + +public sealed class PingCommandTests +{ + /// + /// Tests that the ping command emits an ephemeral "Pong!" response. + /// + /// This test verifies that calling the ping command results in the + /// expected ephemeral "Pong!" response. + /// + [Fact(DisplayName = "User should be able to issue the Ping command.")] + public static async Task PingCommand_Send_ShouldEmitEphemeralPong() + { + // Arrange + var command = TestUtilities.SetupCommandMock(); + + // Act + await command.Object.Ping(); + + // Assert + TestUtilities.VerifyMessage(command, "Pong!", true); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs new file mode 100644 index 0000000..195d94f --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -0,0 +1,180 @@ +using Discord; +using FluentAssertions; +using InstarBot.Tests.Services; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.Modals; +using Xunit; + +namespace InstarBot.Tests.Integration.Interactions; + +public sealed class ReportUserTests +{ + [Fact(DisplayName = "User should be able to report a message normally")] + public static async Task ReportUser_WhenReportingNormally_ShouldNotifyStaff() + { + var context = ReportContext.Builder() + .FromUser(Snowflake.Generate()) + .Reporting(Snowflake.Generate()) + .InChannel(Snowflake.Generate()) + .WithReason("This is a test report") + .Build(); + + var (command, interactionContext, channelMock) = SetupMocks(context); + + // Act + await command.Object.HandleCommand(interactionContext.Object); + await command.Object.ModalResponse(new ReportMessageModal + { + ReportReason = context.Reason + }); + + // Assert + TestUtilities.VerifyMessage(command, "Your report has been sent.", true); + + channelMock.Verify(n => n.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())); + + Assert.NotNull(context.ResultEmbed); + var embed = context.ResultEmbed; + + embed.Author.Should().NotBeNull(); + embed.Footer.Should().NotBeNull(); + embed.Footer!.Value.Text.Should().Be("Instar Message Reporting System"); + } + + [Fact(DisplayName = "Report user function times out if cache expires")] + public static async Task ReportUser_WhenReportingNormally_ShouldFail_IfNotCompletedWithin5Minutes() + { + var context = ReportContext.Builder() + .FromUser(Snowflake.Generate()) + .Reporting(Snowflake.Generate()) + .InChannel(Snowflake.Generate()) + .WithReason("This is a test report") + .Build(); + + var (command, interactionContext, _) = SetupMocks(context); + + // Act + await command.Object.HandleCommand(interactionContext.Object); + ReportUserCommand.PurgeCache(); + await command.Object.ModalResponse(new ReportMessageModal + { + ReportReason = context.Reason + }); + + // Assert + TestUtilities.VerifyMessage(command, "Report expired. Please try again.", true); + } + + private static (Mock, Mock, Mock) SetupMocks(ReportContext context) + { + var commandMockContext = new TestContext + { + UserID = context.User, + EmbedCallback = embed => context.ResultEmbed = embed, + }; + + var commandMock = + TestUtilities.SetupCommandMock + (() => new ReportUserCommand(TestUtilities.GetDynamicConfiguration(), new MockMetricService()), + commandMockContext); + + return (commandMock, SetupMessageCommandMock(context), commandMockContext.TextChannelMock); + } + + private static Mock SetupMessageCommandMock(ReportContext context) + { + var userMock = TestUtilities.SetupUserMock(context.User); + var authorMock = TestUtilities.SetupUserMock(context.Sender); + + var channelMock = TestUtilities.SetupChannelMock(context.Channel); + + var messageMock = new Mock(); + messageMock.Setup(n => n.Id).Returns(100); + messageMock.Setup(n => n.Author).Returns(authorMock.Object); + messageMock.Setup(n => n.Channel).Returns(channelMock.Object); + + var socketMessageDataMock = new Mock(); + socketMessageDataMock.Setup(n => n.Message).Returns(messageMock.Object); + + var socketMessageCommandMock = new Mock(); + socketMessageCommandMock.Setup(n => n.User).Returns(userMock.Object); + socketMessageCommandMock.Setup(n => n.Data).Returns(socketMessageDataMock.Object); + + socketMessageCommandMock.Setup(n => + n.RespondWithModalAsync(It.IsAny(), It.IsAny(), + It.IsAny>())) + .Returns(Task.CompletedTask); + + return socketMessageCommandMock; + } + + private record ReportContext(Snowflake User, Snowflake Sender, Snowflake Channel, string Reason) + { + public static ReportContextBuilder Builder() + { + return new ReportContextBuilder(); + } + + public Embed? ResultEmbed { get; set; } + } + + private class ReportContextBuilder + { + private Snowflake? _user; + private Snowflake? _sender; + private Snowflake? _channel; + private string? _reason; + + /* + * ReportContext.Builder() + * .FromUser(user) + * .Reporting(userToReport) + * .WithContent(content) + * .InChannel(channel) + * .WithReason(reason); + */ + public ReportContextBuilder FromUser(Snowflake user) + { + _user = user; + return this; + } + + public ReportContextBuilder Reporting(Snowflake userToReport) + { + _sender = userToReport; + return this; + } + + public ReportContextBuilder InChannel(Snowflake channel) + { + _channel = channel; + return this; + } + + public ReportContextBuilder WithReason(string reason) + { + _reason = reason; + return this; + } + + public ReportContext Build() + { + if (_user is null) + throw new InvalidOperationException("User must be set before building ReportContext"); + if (_sender is null) + throw new InvalidOperationException("Sender must be set before building ReportContext"); + if (_channel is null) + throw new InvalidOperationException("Channel must be set before building ReportContext"); + if (_reason is null) + throw new InvalidOperationException("Reason must be set before building ReportContext"); + + return new ReportContext(_user, _sender, _channel, _reason); + } + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs new file mode 100644 index 0000000..a7b7247 --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -0,0 +1,111 @@ +using FluentAssertions; +using InstarBot.Tests.Services; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.Services; +using Xunit; + +namespace InstarBot.Tests.Integration.Interactions; + +public class SetBirthdayCommandTests +{ + private static (IInstarDDBService, Mock) SetupMocks(SetBirthdayContext context) + { + var ddbService = TestUtilities.GetServices().GetService(); + var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService!, new MockMetricService()), new TestContext + { + UserID = context.User.ID, + }); + + Assert.NotNull(ddbService); + + return (ddbService, cmd); + } + + + [Theory(DisplayName = "User should be able to set their birthday when providing a valid date.")] + [InlineData(1992, 7, 21, 0)] + [InlineData(1992, 7, 21, -7)] + [InlineData(1992, 7, 21, 7)] + [InlineData(2000, 7, 21, 0)] + [InlineData(2001, 12, 31, 0)] + [InlineData(2010, 1, 1, 0)] + public static async Task SetBirthdayCommand_WithValidDate_ShouldSetCorrectly(int year, int month, int day, int timezone) + { + // Arrange + var context = new SetBirthdayContext(Snowflake.Generate(), year, month, day, timezone); + + var (ddb, cmd) = SetupMocks(context); + + // Act + await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); + + // Assert + var date = context.ToDateTime(); + (await ddb.GetUserBirthday(context.User.ID)).Should().Be(date.UtcDateTime); + TestUtilities.VerifyMessage(cmd, $"Your birthday was set to {date.DateTime:dddd, MMMM d, yyy}.", true); + } + + [Theory(DisplayName = "Attempting to set an invalid day or month number should emit an error message.")] + [InlineData(1992, 13, 1)] // Invalid month + [InlineData(1992, -7, 1)] // Invalid month + [InlineData(1992, 1, 40)] // Invalid day + [InlineData(1992, 2, 31)] // Leap year + [InlineData(2028, 2, 31)] // Leap year + [InlineData(2032, 2, 31)] // Leap year + public static async Task SetBirthdayCommand_WithInvalidDate_ShouldReturnError(int year, int month, int day) + { + // Arrange + var context = new SetBirthdayContext(Snowflake.Generate(), year, month, day); + + var (_, cmd) = SetupMocks(context); + + // Act + await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); + + // Assert + if (month is < 0 or > 12) + { + TestUtilities.VerifyMessage(cmd, + "There are only 12 months in a year. Your birthday was not set.", true); + } + else + { + var date = new DateTime(context.Year, context.Month, 1); // there's always a 1st of the month + var daysInMonth = DateTime.DaysInMonth(context.Year, context.Month); + + // Assert + TestUtilities.VerifyMessage(cmd, + $"There are only {daysInMonth} days in {date:MMMM yyy}. Your birthday was not set.", true); + } + } + + [Fact(DisplayName = "Attempting to set a birthday in the future should emit an error message.")] + public static async Task SetBirthdayCommand_WithDateInFuture_ShouldReturnError() + { + // Arrange + // Note: Update this in the year 9,999 + var context = new SetBirthdayContext(Snowflake.Generate(), 9999, 1, 1); + + var (_, cmd) = SetupMocks(context); + + // Act + await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); + + // Assert + TestUtilities.VerifyMessage(cmd, "You are not a time traveler. Your birthday was not set.", true); + } + + private record SetBirthdayContext(Snowflake User, int Year, int Month, int Day, int TimeZone = 0) + { + public DateTimeOffset ToDateTime() + { + var unspecifiedDate = new DateTime(Year, Month, Day, 0, 0, 0, DateTimeKind.Unspecified); + var timeZone = new DateTimeOffset(unspecifiedDate, TimeSpan.FromHours(TimeZone)); + + return timeZone; + } + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs deleted file mode 100644 index 8174ff6..0000000 --- a/InstarBot.Tests.Integration/Steps/AutoMemberSystemStepDefinitions.cs +++ /dev/null @@ -1,227 +0,0 @@ -using Discord; -using FluentAssertions; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.ConfigModels; -using PaxAndromeda.Instar.Gaius; -using PaxAndromeda.Instar.Services; - -namespace InstarBot.Tests.Integration; - -[Binding] -public class AutoMemberSystemStepDefinitions(ScenarioContext scenarioContext) -{ - private readonly Dictionary _roleNameIDMap = new(); - - [Given("the roles as follows:")] - public void GivenTheRolesAsFollows(Table table) - { - foreach (var row in table.Rows) - { - _roleNameIDMap.Add(row["Role Name"], ulong.Parse(row["Role ID"])); - } - } - - private async Task SetupTest() - { - var context = scenarioContext.Get("Context"); - var discordService = TestUtilities.SetupDiscordService(context); - var gaiusApiService = TestUtilities.SetupGaiusAPIService(context); - var config = TestUtilities.GetDynamicConfiguration(); - scenarioContext.Add("Config", config); - scenarioContext.Add("DiscordService", discordService); - - var userId = scenarioContext.Get("UserID"); - var relativeJoinTime = scenarioContext.Get("UserAge"); - var roles = scenarioContext.Get("UserRoles").Select(roleId => new Snowflake(roleId)).ToArray(); - var postedIntro = scenarioContext.Get("UserPostedIntroduction"); - var messagesLast24Hours = scenarioContext.Get("UserMessagesPast24Hours"); - var firstSeenTime = scenarioContext.ContainsKey("UserFirstJoinedTime") ? scenarioContext.Get("UserFirstJoinedTime") : 0; - var grantedMembershipBefore = scenarioContext.ContainsKey("UserGrantedMembershipBefore") && scenarioContext.Get("UserGrantedMembershipBefore"); - var amsConfig = scenarioContext.Get("AMSConfig"); - - var ddbService = new MockInstarDDBService(); - if (firstSeenTime > 0) - await ddbService.UpdateUserJoinDate(userId, DateTime.UtcNow - TimeSpan.FromHours(firstSeenTime)); - if (grantedMembershipBefore) - await ddbService.UpdateUserMembership(userId, grantedMembershipBefore); - - context.AddRoles(roles); - - var user = new TestGuildUser - { - Id = userId, - JoinedAt = DateTimeOffset.Now - TimeSpan.FromHours(relativeJoinTime), - RoleIds = roles.Select(n => n.ID).ToList().AsReadOnly() - }; - - context.GuildUsers.Add(user); - - - var genericChannel = Snowflake.Generate(); - context.AddChannel(amsConfig.IntroductionChannel); - - context.AddChannel(genericChannel); - if (postedIntro) - context.GetChannel(amsConfig.IntroductionChannel).AddMessage(user, "Some text"); - - for (var i = 0; i < messagesLast24Hours; i++) - context.GetChannel(genericChannel).AddMessage(user, "Some text"); - - - var ams = new AutoMemberSystem(config, discordService, gaiusApiService, ddbService, new MockMetricService()); - scenarioContext.Add("AutoMemberSystem", ams); - scenarioContext.Add("User", user); - - return ams; - } - - [When("the Auto Member System processes")] - public async Task WhenTheAutoMemberSystemProcesses() - { - var ams = await SetupTest(); - - await ams.RunAsync(); - } - - [Then("the user should remain unchanged")] - public void ThenTheUserShouldRemainUnchanged() - { - var userId = scenarioContext.Get("UserID"); - var context = scenarioContext.Get("Context"); - var user = context.GuildUsers.First(n => n.Id == userId.ID) as TestGuildUser; - - user.Should().NotBeNull(); - user.Changed.Should().BeFalse(); - } - - [Given("Been issued a warning")] - public void GivenBeenIssuedAWarning() - { - var userId = scenarioContext.Get("UserID"); - var context = scenarioContext.Get("Context"); - - context.AddWarning(userId, new Warning - { - Reason = "TEST WARNING", - ModID = Snowflake.Generate(), - UserID = userId - }); - } - - [Given("Been issued a mute")] - public void GivenBeenIssuedAMute() - { - var userId = scenarioContext.Get("UserID"); - var context = scenarioContext.Get("Context"); - - context.AddCaselog(userId, new Caselog - { - Type = CaselogType.Mute, - Reason = "TEST WARNING", - ModID = Snowflake.Generate(), - UserID = userId - }); - } - - [Given("the Gaius API is not available")] - public void GivenTheGaiusApiIsNotAvailable() - { - var context = scenarioContext.Get("Context"); - context.InhibitGaius = true; - } - - [Given("a user that has:")] - public async Task GivenAUserThatHas() - { - var config = await TestUtilities.GetDynamicConfiguration().GetConfig(); - var amsConfig = config.AutoMemberConfig; - scenarioContext.Add("AMSConfig", amsConfig); - - var cmc = new TestContext(); - scenarioContext.Add("Context", cmc); - - var userId = Snowflake.Generate(); - scenarioContext.Add("UserID", userId); - } - - [Given("Joined (.*) hours ago")] - public void GivenJoinedHoursAgo(int ageHours) => scenarioContext.Add("UserAge", ageHours); - - [Given("The roles (.*)")] - public void GivenTheRoles(string roles) - { - var roleNames = roles.Split([",", "and"], - StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - - var roleIds = roleNames.Select(roleName => _roleNameIDMap[roleName]).ToArray(); - - scenarioContext.Add("UserRoles", roleIds); - } - - [Given("Posted an introduction")] - public void GivenPostedAnIntroduction() => scenarioContext.Add("UserPostedIntroduction", true); - - [Given("Did not post an introduction")] - public void GivenDidNotPostAnIntroduction() => scenarioContext.Add("UserPostedIntroduction", false); - - [Given("Posted (.*) messages in the past day")] - public void GivenPostedMessagesInThePastDay(int numMessages) => scenarioContext.Add("UserMessagesPast24Hours", numMessages); - - [Then("the user should be granted membership")] - public async Task ThenTheUserShouldBeGrantedMembership() - { - var userId = scenarioContext.Get("UserID"); - var context = scenarioContext.Get("Context"); - var config = scenarioContext.Get("Config"); - var user = context.GuildUsers.First(n => n.Id == userId.ID); - - var cfg = await config.GetConfig(); - - user.RoleIds.Should().Contain(cfg.MemberRoleID); - user.RoleIds.Should().NotContain(cfg.NewMemberRoleID); - } - - [Then("the user should not be granted membership")] - public async Task ThenTheUserShouldNotBeGrantedMembership() - { - var userId = scenarioContext.Get("UserID"); - var context = scenarioContext.Get("Context"); - var config = scenarioContext.Get("Config"); - var user = context.GuildUsers.First(n => n.Id == userId.ID); - - var cfg = await config.GetConfig(); - - user.RoleIds.Should().Contain(cfg.NewMemberRoleID); - user.RoleIds.Should().NotContain(cfg.MemberRoleID); - } - - [Given("Not been punished")] - public void GivenNotBeenPunished() - { - // ignore - } - - [Given("First joined (.*) hours ago")] - public void GivenFirstJoinedHoursAgo(int hoursAgo) => scenarioContext.Add("UserFirstJoinedTime", hoursAgo); - - [Given("Joined the server for the first time")] - public void GivenJoinedTheServerForTheFirstTime() => scenarioContext.Add("UserFirstJoinedTime", 0); - - [Given("Been granted membership before")] - public void GivenBeenGrantedMembershipBefore() => scenarioContext.Add("UserGrantedMembershipBefore", true); - - [Given("Not been granted membership before")] - public void GivenNotBeenGrantedMembershipBefore() => scenarioContext.Add("UserGrantedMembershipBefore", false); - - [When("the user joins the server")] - public async Task WhenTheUserJoinsTheServer() - { - await SetupTest(); - var service = scenarioContext.Get("DiscordService") as MockDiscordService; - var user = scenarioContext.Get("User"); - - service?.TriggerUserJoined(user); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs deleted file mode 100644 index 064ac00..0000000 --- a/InstarBot.Tests.Integration/Steps/BirthdayCommandStepDefinitions.cs +++ /dev/null @@ -1,72 +0,0 @@ -using FluentAssertions; -using InstarBot.Tests.Services; -using Microsoft.Extensions.DependencyInjection; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Commands; -using PaxAndromeda.Instar.Services; -using TechTalk.SpecFlow.Assist; -using Xunit; - -namespace InstarBot.Tests.Integration; - -[Binding] -public class BirthdayCommandStepDefinitions(ScenarioContext context) -{ - [Given("the user provides the following parameters")] - public void GivenTheUserProvidesTheFollowingParameters(Table table) - { - var dict = table.Rows.ToDictionary(n => n["Key"], n => n.GetInt32("Value")); - - // Let's see if we have the bare minimum - Assert.True(dict.ContainsKey("Year") && dict.ContainsKey("Month") && dict.ContainsKey("Day")); - - context.Add("Year", dict["Year"]); - context.Add("Month", dict["Month"]); - context.Add("Day", dict["Day"]); - - if (dict.TryGetValue("Timezone", out var value)) - context.Add("Timezone", value); - } - - [When("the user calls the Set Birthday command")] - public async Task WhenTheUserCallsTheSetBirthdayCommand() - { - var year = context.Get("Year"); - var month = context.Get("Month"); - var day = context.Get("Day"); - var timezone = context.ContainsKey("Timezone") ? context.Get("Timezone") : 0; - - var userId = new Snowflake().ID; - - var ddbService = TestUtilities.GetServices().GetService(); - var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService!, new MockMetricService()), new TestContext - { - UserID = userId - }); - context.Add("Command", cmd); - context.Add("UserID", userId); - context.Add("DDB", ddbService); - - await cmd.Object.SetBirthday((Month)month, day, year, timezone); - } - - [Then("DynamoDB should have the user's (Birthday|JoinDate) set to (.*)")] - public async Task ThenDynamoDbShouldHaveBirthdaySetTo(string dataType, DateTime time) - { - var ddbService = context.Get("DDB"); - var userId = context.Get("UserID"); - - switch (dataType) - { - case "Birthday": - (await ddbService!.GetUserBirthday(userId)).Should().Be(time.ToUniversalTime()); - break; - case "JoinDate": - (await ddbService!.GetUserJoinDate(userId)).Should().Be(time.ToUniversalTime()); - break; - default: - Assert.Fail("Invalid test setup: dataType is unknown"); - break; - } - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs deleted file mode 100644 index 23ca192..0000000 --- a/InstarBot.Tests.Integration/Steps/PageCommandStepDefinitions.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Discord; -using FluentAssertions; -using InstarBot.Tests.Services; -using Moq; -using Moq.Protected; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Commands; - -namespace InstarBot.Tests.Integration; - -[Binding] -public sealed class PageCommandStepDefinitions(ScenarioContext scenarioContext) -{ - [Given("the user is in team (.*)")] - public async Task GivenTheUserIsInTeam(PageTarget target) - { - var team = await TestUtilities.GetTeams(target).FirstAsync(); - scenarioContext.Add("UserTeamID", team.ID); - } - - [Given("the user is not a staff member")] - public void GivenTheUserIsNotAStaffMember() - { - scenarioContext.Add("UserTeamID", new Snowflake()); - } - - [Given("the user is paging (Helper|Moderator|Admin|Owner|Test|All)")] - [SuppressMessage("ReSharper", "SpecFlow.MethodNameMismatchPattern")] - public void GivenTheUserIsPaging(PageTarget target) - { - scenarioContext.Add("PageTarget", target); - scenarioContext.Add("PagingTeamLeader", false); - } - - [Given("the user is paging the (Helper|Moderator|Admin|Owner|Test|All) teamleader")] - [SuppressMessage("ReSharper", "SpecFlow.MethodNameMismatchPattern")] - public void GivenTheUserIsPagingTheTeamTeamleader(PageTarget target) - { - scenarioContext.Add("PageTarget", target); - scenarioContext.Add("PagingTeamLeader", true); - } - - [When("the user calls the Page command")] - public async Task WhenTheUserCallsThePageCommand() - { - scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); - scenarioContext.ContainsKey("PagingTeamLeader").Should().BeTrue(); - var pageTarget = scenarioContext.Get("PageTarget"); - var pagingTeamLeader = scenarioContext.Get("PagingTeamLeader"); - - var command = SetupMocks(); - scenarioContext.Add("Command", command); - - await command.Object.Page(pageTarget, "This is a test reason", pagingTeamLeader); - } - - [Then("Instar should emit a valid Page embed")] - public async Task ThenInstarShouldEmitAValidPageEmbed() - { - scenarioContext.ContainsKey("Command").Should().BeTrue(); - scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); - var command = scenarioContext.Get>("Command"); - var pageTarget = scenarioContext.Get("PageTarget"); - - string expectedString; - - if (pageTarget == PageTarget.Test) - { - expectedString = "This is a __**TEST**__ page."; - } - else - { - var team = await TestUtilities.GetTeams(pageTarget).FirstAsync(); - expectedString = Snowflake.GetMention(() => team.ID); - } - - command.Protected().Verify( - "RespondAsync", Times.Once(), - expectedString, ItExpr.IsNull(), - false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); - } - - [Then("Instar should emit a valid teamleader Page embed")] - public async Task ThenInstarShouldEmitAValidTeamleaderPageEmbed() - { - scenarioContext.ContainsKey("Command").Should().BeTrue(); - scenarioContext.ContainsKey("PageTarget").Should().BeTrue(); - - var command = scenarioContext.Get>("Command"); - var pageTarget = scenarioContext.Get("PageTarget"); - - command.Protected().Verify( - "RespondAsync", Times.Once(), - $"<@{await GetTeamLead(pageTarget)}>", ItExpr.IsNull(), - false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); - } - - private static async Task GetTeamLead(PageTarget pageTarget) - { - var dynamicConfig = await TestUtilities.GetDynamicConfiguration().GetConfig(); - - var teamsConfig = - dynamicConfig.Teams.ToDictionary(n => n.InternalID, n => n); - - // Eeeeeeeeeeeeevil - return teamsConfig[pageTarget.GetAttributesOfType()?.First().InternalID ?? "idkjustfail"] - .Teamleader; - } - - [Then("Instar should emit a valid All Page embed")] - public async Task ThenInstarShouldEmitAValidAllPageEmbed() - { - scenarioContext.ContainsKey("Command").Should().BeTrue(); - var command = scenarioContext.Get>("Command"); - var expected = string.Join(' ', - await TestUtilities.GetTeams(PageTarget.All).Select(n => Snowflake.GetMention(() => n.ID)).ToArrayAsync()); - - command.Protected().Verify( - "RespondAsync", Times.Once(), - expected, ItExpr.IsNull(), - false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); - } - - private Mock SetupMocks() - { - var userTeam = scenarioContext.Get("UserTeamID"); - - var commandMock = TestUtilities.SetupCommandMock( - () => new PageCommand(TestUtilities.GetTeamService(), new MockMetricService()), - new TestContext - { - UserRoles = [userTeam] - }); - - return commandMock; - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs deleted file mode 100644 index ff96def..0000000 --- a/InstarBot.Tests.Integration/Steps/PingCommandStepDefinitions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using PaxAndromeda.Instar.Commands; - -namespace InstarBot.Tests.Integration; - -[Binding] -public class PingCommandStepDefinitions(ScenarioContext context) -{ - [When("the user calls the Ping command")] - public async Task WhenTheUserCallsThePingCommand() - { - var command = TestUtilities.SetupCommandMock(); - context.Add("Command", command); - - await command.Object.Ping(); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs b/InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs deleted file mode 100644 index ddb7a73..0000000 --- a/InstarBot.Tests.Integration/Steps/ReportUserCommandStepDefinitions.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Discord; -using FluentAssertions; -using InstarBot.Tests.Services; -using Moq; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Commands; -using PaxAndromeda.Instar.Modals; -using TechTalk.SpecFlow.Assist; -using Xunit; - -namespace InstarBot.Tests.Integration; - -[Binding] -public class ReportUserCommandStepDefinitions(ScenarioContext context) -{ - [When("the user (.*) reports a message with the following properties")] - public async Task WhenTheUserReportsAMessageWithTheFollowingProperties(ulong userId, Table table) - { - context.Add("ReportingUserID", userId); - - var messageProperties = table.Rows.ToDictionary(n => n["Key"], n => n); - - Assert.True(messageProperties.ContainsKey("Content")); - Assert.True(messageProperties.ContainsKey("Sender")); - Assert.True(messageProperties.ContainsKey("Channel")); - - context.Add("MessageContent", messageProperties["Content"].GetString("Value")); - context.Add("MessageSender", (ulong)messageProperties["Sender"].GetInt64("Value")); - context.Add("MessageChannel", (ulong)messageProperties["Channel"].GetInt64("Value")); - - var (command, interactionContext) = SetupMocks(); - context.Add("Command", command); - context.Add("InteractionContext", interactionContext); - - await command.Object.HandleCommand(interactionContext.Object); - } - - [When("does not complete the modal within 5 minutes")] - public async Task WhenDoesNotCompleteTheModalWithinMinutes() - { - Assert.True(context.ContainsKey("Command")); - var command = context.Get>("Command"); - - ReportUserCommand.PurgeCache(); - context.Add("ReportReason", string.Empty); - - await command.Object.ModalResponse(new ReportMessageModal - { - ReportReason = string.Empty - }); - } - - [When(@"completes the report modal with reason ""(.*)""")] - public async Task WhenCompletesTheReportModalWithReason(string reportReason) - { - Assert.True(context.ContainsKey("Command")); - var command = context.Get>("Command"); - context.Add("ReportReason", reportReason); - - await command.Object.ModalResponse(new ReportMessageModal - { - ReportReason = reportReason - }); - } - - [Then("Instar should emit a message report embed")] - public void ThenInstarShouldEmitAMessageReportEmbed() - { - Assert.True(context.ContainsKey("TextChannelMock")); - var textChannel = context.Get>("TextChannelMock"); - - textChannel.Verify(n => n.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny())); - - Assert.True(context.ContainsKey("ResultEmbed")); - var embed = context.Get("ResultEmbed"); - - embed.Author.Should().NotBeNull(); - embed.Footer.Should().NotBeNull(); - embed.Footer!.Value.Text.Should().Be("Instar Message Reporting System"); - } - - private (Mock, Mock) SetupMocks() - { - var commandMockContext = new TestContext - { - UserID = context.Get("ReportingUserID"), - EmbedCallback = embed => context.Add("ResultEmbed", embed) - }; - - var commandMock = - TestUtilities.SetupCommandMock - (() => new ReportUserCommand(TestUtilities.GetDynamicConfiguration(), new MockMetricService()), - commandMockContext); - context.Add("TextChannelMock", commandMockContext.TextChannelMock); - - return (commandMock, SetupMessageCommandMock()); - } - - private Mock SetupMessageCommandMock() - { - var userMock = TestUtilities.SetupUserMock(context.Get("ReportingUserID")); - var authorMock = TestUtilities.SetupUserMock(context.Get("MessageSender")); - - var channelMock = TestUtilities.SetupChannelMock(context.Get("MessageChannel")); - - var messageMock = new Mock(); - messageMock.Setup(n => n.Id).Returns(100); - messageMock.Setup(n => n.Author).Returns(authorMock.Object); - messageMock.Setup(n => n.Channel).Returns(channelMock.Object); - - var socketMessageDataMock = new Mock(); - socketMessageDataMock.Setup(n => n.Message).Returns(messageMock.Object); - - var socketMessageCommandMock = new Mock(); - socketMessageCommandMock.Setup(n => n.User).Returns(userMock.Object); - socketMessageCommandMock.Setup(n => n.Data).Returns(socketMessageDataMock.Object); - - socketMessageCommandMock.Setup(n => - n.RespondWithModalAsync(It.IsAny(), It.IsAny(), - It.IsAny>())) - .Returns(Task.CompletedTask); - - return socketMessageCommandMock; - } -} \ No newline at end of file diff --git a/InstarBot/Commands/SetBirthdayCommand.cs b/InstarBot/Commands/SetBirthdayCommand.cs index 40ef1b6..074ec21 100644 --- a/InstarBot/Commands/SetBirthdayCommand.cs +++ b/InstarBot/Commands/SetBirthdayCommand.cs @@ -30,6 +30,14 @@ public async Task SetBirthday( [Autocomplete] int tzOffset = 0) { + if ((int)month is < 0 or > 12) + { + await RespondAsync( + "There are only 12 months in a year. Your birthday was not set.", + ephemeral: true); + return; + } + var daysInMonth = DateTime.DaysInMonth(year, (int)month); // First step: Does the provided number of days exceed the number of days in the given month? @@ -41,8 +49,11 @@ await RespondAsync( return; } - var dtLocal = new DateTime(year, (int)month, day, 0, 0, 0, DateTimeKind.Unspecified); - var dtUtc = new DateTime(year, (int)month, day, 0, 0, 0, DateTimeKind.Utc).AddHours(-tzOffset); + var unspecifiedDate = new DateTime(year, (int)month, day, 0, 0, 0, DateTimeKind.Unspecified); + var dtZ = new DateTimeOffset(unspecifiedDate, TimeSpan.FromHours(tzOffset)); + + var dtLocal = dtZ.DateTime; + var dtUtc = dtZ.UtcDateTime; // Second step: Is the provided birthday actually in the future? if (dtUtc > DateTime.UtcNow) From fa42159ee43dab17fb2859e7b01cdf058d750aef Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Wed, 2 Jul 2025 12:37:37 -0700 Subject: [PATCH 14/53] Refactored parts of AutoMemberSystem - PreloadIntroductionPosters now uses an enumerable based approach, optimizing the logic. - GetMessagesSent now uses a Linq method based approach for easy reading and efficiency. - Optimized recent messages lookup in CheckEligibility --- InstarBot/Services/AutoMemberSystem.cs | 44 ++++++++++++++++---------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index 63d320a..a7eb49b 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -279,7 +279,7 @@ public async Task CheckEligibility(IGuildUser user) if (!_introductionPosters.ContainsKey(user.Id)) eligibility |= MembershipEligibility.MissingIntroduction; - if (!_recentMessages.ContainsKey(user.Id) || _recentMessages[user.Id] < cfg.AutoMemberConfig.MinimumMessages) + if (_recentMessages.TryGetValue(user.Id, out int messages) && messages < cfg.AutoMemberConfig.MinimumMessages) eligibility |= MembershipEligibility.NotEnoughMessages; if (_punishedUsers.ContainsKey(user.Id)) @@ -306,13 +306,11 @@ private static bool CheckUserRequiredRoles(InstarDynamicConfiguration cfg, IGuil private Dictionary GetMessagesSent() { - var map = new Dictionary(); - - foreach (var cacheEntry in _messageCache) - { - if (!map.TryAdd(cacheEntry.Value.UserID, 1)) - map[cacheEntry.Value.UserID]++; - } + var map = this._messageCache + .Cast>() // Cast to access LINQ extensions + .Select(entry => entry.Value) + .GroupBy(properties => properties.UserID) + .ToDictionary(group => group.Key, group => group.Count()); return map; } @@ -347,24 +345,36 @@ private async Task PreloadIntroductionPosters(InstarDynamicConfiguration cfg) { if (await _discord.GetChannel(cfg.AutoMemberConfig.IntroductionChannel) is not ITextChannel introChannel) throw new InvalidOperationException("Introductions channel not found"); - - var messages = (await introChannel.GetMessagesAsync().FlattenAsync()).ToList(); + + var messages = (await introChannel.GetMessagesAsync().FlattenAsync()).GetEnumerator(); // Assumption: Last message is the oldest one - while (messages.Count > 0) + while (messages.MoveNext()) // Move to the first message, if there is any { - var oldestMessage = messages[0]; - foreach (var message in messages) + IMessage? message; + IMessage? oldestMessage = null; + + do { + message = messages.Current; + if (message is null) + break; + if (message.Author is IGuildUser sgUser && sgUser.RoleIds.Contains(cfg.MemberRoleID.ID)) continue; - + _introductionPosters.TryAdd(message.Author.Id, true); - if (message.Timestamp < oldestMessage.Timestamp) + + if (oldestMessage is null || message.Timestamp < oldestMessage.Timestamp) oldestMessage = message; - } + } while (messages.MoveNext()); + + if (message is null || oldestMessage is null) + break; - messages = (await introChannel.GetMessagesAsync(oldestMessage, Direction.Before).FlattenAsync()).ToList(); + messages = (await introChannel.GetMessagesAsync(oldestMessage, Direction.Before).FlattenAsync()).GetEnumerator(); } + + messages.Dispose(); } } \ No newline at end of file From 210b20e8316c7548d2a3f90ce78b2a4a053a21b3 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sun, 6 Jul 2025 09:37:56 -0700 Subject: [PATCH 15/53] Re-add Community Manager Team This commit partially reverts commit 20866da67d5dee92f47aeaebaadcb0342230ffcd --- .../Config/Instar.dynamic.test.conf.json | 8 ++++++++ .../Config/Instar.dynamic.test.debug.conf.json | 8 ++++++++ InstarBot/Commands/PageCommand.cs | 2 +- InstarBot/PageTarget.cs | 6 +++++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/InstarBot.Tests.Common/Config/Instar.dynamic.test.conf.json b/InstarBot.Tests.Common/Config/Instar.dynamic.test.conf.json index 5cac2dc..8a70ef5 100644 --- a/InstarBot.Tests.Common/Config/Instar.dynamic.test.conf.json +++ b/InstarBot.Tests.Common/Config/Instar.dynamic.test.conf.json @@ -93,6 +93,14 @@ "Teamleader": 459078815314870283, "Color": 13532979, "Priority": 4 + }, + { + "InternalID": "fe434e9a-2a69-41b6-a297-24e26ba4aebe", + "Name": "Community Manager", + "ID": 957411837920567356, + "Teamleader": 340546691491168257, + "Color": 10892756, + "Priority": 5 } ] } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json b/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json index 58b3e68..a8d4466 100644 --- a/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json +++ b/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json @@ -91,6 +91,14 @@ "Teamleader": 459078815314870283, "Color": 13532979, "Priority": 4 + }, + { + "InternalID": "fe434e9a-2a69-41b6-a297-24e26ba4aebe", + "Name": "Community Manager", + "ID": 957411837920567356, + "Teamleader": 340546691491168257, + "Color": 10892756, + "Priority": 5 } ] } \ No newline at end of file diff --git a/InstarBot/Commands/PageCommand.cs b/InstarBot/Commands/PageCommand.cs index 7818e81..453f338 100644 --- a/InstarBot/Commands/PageCommand.cs +++ b/InstarBot/Commands/PageCommand.cs @@ -97,7 +97,7 @@ private static bool CheckPermissions(IGuildUser user, Team? team, PageTarget pag return true; // Check permissions. Only mod+ can send an "all" page - if (team.Priority > 3 && pageTarget == PageTarget.All) // i.e. Helper + if (team.Priority > 3 && pageTarget == PageTarget.All) // i.e. Helper, Community Manager { response = "You are not authorized to send a page to the entire staff team."; Log.Information("{User} was not authorized to send a page to the entire staff team", user.Id); diff --git a/InstarBot/PageTarget.cs b/InstarBot/PageTarget.cs index 4b4d523..c0da141 100644 --- a/InstarBot/PageTarget.cs +++ b/InstarBot/PageTarget.cs @@ -1,4 +1,5 @@ -using JetBrains.Annotations; +using Discord.Interactions; +using JetBrains.Annotations; namespace PaxAndromeda.Instar; @@ -14,6 +15,9 @@ public enum PageTarget Moderator, [TeamRef("521dce27-9ed9-48fc-9615-dc1d77b72fdd"), UsedImplicitly] Helper, + [TeamRef("fe434e9a-2a69-41b6-a297-24e26ba4aebe"), UsedImplicitly] + [ChoiceDisplay("Community Manager")] + CommunityManager, [TeamRef("ffcf94e3-3080-455a-82e2-7cd9ec7eaafd")] [TeamRef("4e484ea5-3cd1-46d4-8fe8-666e34f251ad")] From c1ef7d9527e5510ad6b555a5be086e2478047baf Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sun, 6 Jul 2025 10:56:57 -0700 Subject: [PATCH 16/53] Fixed some code quality and test adapter issues - Fixed a merge issue in GaiusAPIService.cs - Added necessary VS2022 test adapters to test packages --- InstarBot.Tests.Common/InstarBot.Tests.Common.csproj | 3 +++ .../InstarBot.Tests.Integration.csproj | 2 ++ InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj | 2 ++ InstarBot/Services/GaiusAPIService.cs | 12 +++++++----- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj index 90dc105..f16bfad 100644 --- a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj +++ b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj @@ -11,7 +11,10 @@ + + + diff --git a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj index 157a6ad..2041b84 100644 --- a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj +++ b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj @@ -10,6 +10,8 @@ + + all diff --git a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj index 51e7d64..dd7af44 100644 --- a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj +++ b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj @@ -14,6 +14,8 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/InstarBot/Services/GaiusAPIService.cs b/InstarBot/Services/GaiusAPIService.cs index 63236cc..e1db0b0 100644 --- a/InstarBot/Services/GaiusAPIService.cs +++ b/InstarBot/Services/GaiusAPIService.cs @@ -117,12 +117,12 @@ internal static IEnumerable ParseCaselogs(string response) // Remove any instances of "totalCases" while (response.Contains("\"totalcases\":", StringComparison.OrdinalIgnoreCase)) { - var start = response.IndexOf("\"totalcases\":", StringComparison.OrdinalIgnoreCase); - var end = response.IndexOfAny(new[] { ',', '}' }, start); + var start = response.IndexOf("\"totalcases\":", StringComparison.OrdinalIgnoreCase); + var end = response.IndexOfAny([',', '}'], start); response = response.Remove(start, end - start + (response[end] == ',' ? 1 : 0)); } - + if (response.Length <= 2) yield break; @@ -144,8 +144,10 @@ private async Task Get(string url) private HttpRequestMessage CreateRequest(string url) { - var hrm = new HttpRequestMessage(); - hrm.RequestUri = new Uri(url); + var hrm = new HttpRequestMessage + { + RequestUri = new Uri(url) + }; hrm.Headers.Add("Accept", "application/json"); hrm.Headers.Add("api-key", _apiKey); From d563ac670831e93748bf2b9f0e09369ccb19a060 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sun, 6 Jul 2025 11:40:06 -0700 Subject: [PATCH 17/53] Fixed some more code quality issues. --- InstarBot.Tests.Common/Models/TestMessage.cs | 60 +++++++++---------- .../Services/MockDiscordService.cs | 2 +- InstarBot.Tests.Common/TestContext.cs | 16 ++--- InstarBot.Tests.Common/TestUtilities.cs | 42 ++----------- .../Interactions/AutoMemberSystemTests.cs | 18 +++--- .../Interactions/PageCommandTests.cs | 2 +- .../Interactions/PingCommandTests.cs | 2 +- .../Interactions/ReportUserTests.cs | 4 +- .../Interactions/SetBirthdayCommandTests.cs | 4 +- .../RequireStaffMemberAttributeTests.cs | 7 ++- InstarBot/Commands/CheckEligibilityCommand.cs | 2 +- InstarBot/Program.cs | 2 +- InstarBot/Services/AutoMemberSystem.cs | 4 +- InstarBot/Services/CloudwatchMetricService.cs | 2 +- InstarBot/Services/GaiusAPIService.cs | 2 +- InstarBot/Utilities.cs | 6 +- 16 files changed, 69 insertions(+), 106 deletions(-) diff --git a/InstarBot.Tests.Common/Models/TestMessage.cs b/InstarBot.Tests.Common/Models/TestMessage.cs index 6f51fbb..f79f5e4 100644 --- a/InstarBot.Tests.Common/Models/TestMessage.cs +++ b/InstarBot.Tests.Common/Models/TestMessage.cs @@ -16,8 +16,8 @@ internal TestMessage(IUser user, string message) Content = message; } - public ulong Id { get; set; } - public DateTimeOffset CreatedAt { get; set; } + public ulong Id { get; } + public DateTimeOffset CreatedAt { get; } public Task DeleteAsync(RequestOptions options = null!) { @@ -54,34 +54,34 @@ public IAsyncEnumerable> GetReactionUsersAsync(IEmote throw new NotImplementedException(); } - public MessageType Type { get; set; } = default; - public MessageSource Source { get; set; } = default; - public bool IsTTS { get; set; } = false; - public bool IsPinned { get; set; } = false; - public bool IsSuppressed { get; set; } = false; - public bool MentionedEveryone { get; set; } = false; - public string Content { get; set; } - public string CleanContent { get; set; } = null!; - public DateTimeOffset Timestamp { get; set; } - public DateTimeOffset? EditedTimestamp { get; set; } = null; - public IMessageChannel Channel { get; set; } = null!; - public IUser Author { get; set; } - public IThreadChannel Thread { get; set; } = null!; - public IReadOnlyCollection Attachments { get; set; } = null!; - public IReadOnlyCollection Embeds { get; set; } = null!; - public IReadOnlyCollection Tags { get; set; } = null!; - public IReadOnlyCollection MentionedChannelIds { get; set; } = null!; - public IReadOnlyCollection MentionedRoleIds { get; set; } = null!; - public IReadOnlyCollection MentionedUserIds { get; set; } = null!; - public MessageActivity Activity { get; set; } = null!; - public MessageApplication Application { get; set; } = null!; - public MessageReference Reference { get; set; } = null!; - public IReadOnlyDictionary Reactions { get; set; } = null!; - public IReadOnlyCollection Components { get; set; } = null!; - public IReadOnlyCollection Stickers { get; set; } = null!; - public MessageFlags? Flags { get; set; } = null; - public IMessageInteraction Interaction { get; set; } = null!; - public MessageRoleSubscriptionData RoleSubscriptionData { get; set; } = null!; + public MessageType Type => default; + public MessageSource Source => default; + public bool IsTTS => false; + public bool IsPinned => false; + public bool IsSuppressed => false; + public bool MentionedEveryone => false; + public string Content { get; } + public string CleanContent => null!; + public DateTimeOffset Timestamp { get; } + public DateTimeOffset? EditedTimestamp => null; + public IMessageChannel Channel => null!; + public IUser Author { get; } + public IThreadChannel Thread => null!; + public IReadOnlyCollection Attachments => null!; + public IReadOnlyCollection Embeds => null!; + public IReadOnlyCollection Tags => null!; + public IReadOnlyCollection MentionedChannelIds => null!; + public IReadOnlyCollection MentionedRoleIds => null!; + public IReadOnlyCollection MentionedUserIds => null!; + public MessageActivity Activity => null!; + public MessageApplication Application => null!; + public MessageReference Reference => null!; + public IReadOnlyDictionary Reactions => null!; + public IReadOnlyCollection Components => null!; + public IReadOnlyCollection Stickers => null!; + public MessageFlags? Flags => null; + public IMessageInteraction Interaction => null!; + public MessageRoleSubscriptionData RoleSubscriptionData => null!; public PurchaseNotification PurchaseNotification => throw new NotImplementedException(); diff --git a/InstarBot.Tests.Common/Services/MockDiscordService.cs b/InstarBot.Tests.Common/Services/MockDiscordService.cs index 5f2ad8c..b1af3a7 100644 --- a/InstarBot.Tests.Common/Services/MockDiscordService.cs +++ b/InstarBot.Tests.Common/Services/MockDiscordService.cs @@ -53,7 +53,7 @@ public Task> GetAllUsers() public Task GetChannel(Snowflake channelId) { - return Task.FromResult(_guild.GetTextChannel(channelId) as IChannel); + return Task.FromResult(_guild.GetTextChannel(channelId)); } public async IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime) diff --git a/InstarBot.Tests.Common/TestContext.cs b/InstarBot.Tests.Common/TestContext.cs index 7babfc7..866f817 100644 --- a/InstarBot.Tests.Common/TestContext.cs +++ b/InstarBot.Tests.Common/TestContext.cs @@ -8,9 +8,9 @@ namespace InstarBot.Tests; public sealed class TestContext { - public ulong UserID { get; init; } = 1420070400100; - public ulong ChannelID { get; init; } = 1420070400200; - public ulong GuildID { get; init; } = 1420070400300; + public ulong UserID { get; init; }= 1420070400100; + public const ulong ChannelID = 1420070400200; + public const ulong GuildID = 1420070400300; public List UserRoles { get; init; } = []; @@ -18,13 +18,13 @@ public sealed class TestContext public Mock TextChannelMock { get; internal set; } = null!; - public List GuildUsers { get; init; } = []; + public List GuildUsers { get; } = []; - public Dictionary Channels { get; init; } = []; - public Dictionary Roles { get; init; } = []; + public Dictionary Channels { get; } = []; + public Dictionary Roles { get; } = []; - public Dictionary> Warnings { get; init; } = []; - public Dictionary> Caselogs { get; init; } = []; + public Dictionary> Warnings { get; } = []; + public Dictionary> Caselogs { get; } = []; public bool InhibitGaius { get; set; } diff --git a/InstarBot.Tests.Common/TestUtilities.cs b/InstarBot.Tests.Common/TestUtilities.cs index 5d3b918..153ac47 100644 --- a/InstarBot.Tests.Common/TestUtilities.cs +++ b/InstarBot.Tests.Common/TestUtilities.cs @@ -12,7 +12,6 @@ using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.Services; -using Xunit; namespace InstarBot.Tests; @@ -64,37 +63,6 @@ public static IServiceProvider GetServices() return sc.BuildServiceProvider(); } - /// - /// Provides a method for verifying messages with an ambiguous Mock type. - /// - /// A mockup of the command. - /// The message to search for. - /// A flag indicating whether the message should be ephemeral. - public static void VerifyMessage(object mockObject, string message, bool ephemeral = false) - { - // A few checks first - var mockObjectType = mockObject.GetType(); - Assert.Equal(nameof(Mock), mockObjectType.Name[..mockObjectType.Name.LastIndexOf('`')]); - Assert.Single(mockObjectType.GenericTypeArguments); - var commandType = mockObjectType.GenericTypeArguments[0]; - - var genericVerifyMessage = typeof(TestUtilities) - .GetMethods() - .Where(n => n.Name == nameof(VerifyMessage)) - .Select(m => new - { - Method = m, - Params = m.GetParameters(), - Args = m.GetGenericArguments() - }) - .Where(x => x.Args.Length == 1) - .Select(x => x.Method) - .First(); - - var specificMethod = genericVerifyMessage.MakeGenericMethod(commandType); - specificMethod.Invoke(null, [mockObject, message, ephemeral]); - } - /// /// Verifies that the command responded to the user with the correct . /// @@ -167,7 +135,7 @@ private static void ConfigureCommandMock(Mock mock, TestContext? context) .Returns(Task.CompletedTask); } - public static Mock SetupContext(TestContext? context) + public static Mock SetupContext(TestContext context) { var mock = new Mock(); @@ -184,7 +152,7 @@ private static Mock SetupGuildMock(TestContext? context) context.Should().NotBeNull(); var guildMock = new Mock(); - guildMock.Setup(n => n.Id).Returns(context.GuildID); + guildMock.Setup(n => n.Id).Returns(TestContext.GuildID); guildMock.Setup(n => n.GetTextChannel(It.IsAny())) .Returns(context.TextChannelMock.Object); @@ -220,10 +188,10 @@ public static Mock SetupChannelMock(ulong channelId) return channelMock; } - private static Mock SetupChannelMock(TestContext? context) + private static Mock SetupChannelMock(TestContext context) where T : class, IChannel { - var channelMock = SetupChannelMock(context!.ChannelID); + var channelMock = SetupChannelMock(TestContext.ChannelID); if (typeof(T) != typeof(ITextChannel)) return channelMock; @@ -263,7 +231,7 @@ public static async IAsyncEnumerable GetTeams(PageTarget pageTarget) foreach (var internalId in teamRefs) { - if (!teamsConfig.TryGetValue(internalId, out Team? value)) + if (!teamsConfig.TryGetValue(internalId, out var value)) throw new KeyNotFoundException("Failed to find team with internal ID " + internalId); yield return value; diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs index 72d6663..9d282d2 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs @@ -1,5 +1,4 @@ -using Discord; -using FluentAssertions; +using FluentAssertions; using InstarBot.Tests.Models; using InstarBot.Tests.Services; using PaxAndromeda.Instar; @@ -10,7 +9,7 @@ namespace InstarBot.Tests.Integration.Interactions; -public class AutoMemberSystemTests +public static class AutoMemberSystemTests { private static readonly Snowflake NewMember = new(796052052433698817); private static readonly Snowflake Member = new(793611808372031499); @@ -361,7 +360,7 @@ private record AutoMemberSystemContext( public static AutoMemberSystemContextBuilder Builder() => new(); public IDiscordService? DiscordService { get; set; } - public IGuildUser User { get; set; } = null!; + public TestGuildUser? User { get; set; } public void AssertMember() { @@ -379,9 +378,8 @@ public void AssertNotMember() public void AssertUserUnchanged() { - var testUser = User as TestGuildUser; - testUser.Should().NotBeNull(); - testUser.Changed.Should().BeFalse(); + User.Should().NotBeNull(); + User.Changed.Should().BeFalse(); } } @@ -468,16 +466,16 @@ public async Task Build() Type = CaselogType.Mute, Reason = "TEST PUNISHMENT", ModID = Snowflake.Generate(), - UserID = userId, + UserID = userId }); } if (_gaiusWarned) { - testContext.AddWarning(userId, new Warning() + testContext.AddWarning(userId, new Warning { Reason = "TEST PUNISHMENT", ModID = Snowflake.Generate(), - UserID = userId, + UserID = userId }); } diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index c56d268..8ca2607 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -8,7 +8,7 @@ namespace InstarBot.Tests.Integration.Interactions; -public class PageCommandTests +public static class PageCommandTests { private static async Task> SetupCommandMock(PageCommandTestContext context) { diff --git a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs index 8ea3d75..7885fc3 100644 --- a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs @@ -3,7 +3,7 @@ namespace InstarBot.Tests.Integration.Interactions; using Xunit; -public sealed class PingCommandTests +public static class PingCommandTests { /// /// Tests that the ping command emits an ephemeral "Pong!" response. diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs index 195d94f..53d3acd 100644 --- a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -9,7 +9,7 @@ namespace InstarBot.Tests.Integration.Interactions; -public sealed class ReportUserTests +public static class ReportUserTests { [Fact(DisplayName = "User should be able to report a message normally")] public static async Task ReportUser_WhenReportingNormally_ShouldNotifyStaff() @@ -76,7 +76,7 @@ private static (Mock, Mock, var commandMockContext = new TestContext { UserID = context.User, - EmbedCallback = embed => context.ResultEmbed = embed, + EmbedCallback = embed => context.ResultEmbed = embed }; var commandMock = diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs index a7b7247..4812d95 100644 --- a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -9,14 +9,14 @@ namespace InstarBot.Tests.Integration.Interactions; -public class SetBirthdayCommandTests +public static class SetBirthdayCommandTests { private static (IInstarDDBService, Mock) SetupMocks(SetBirthdayContext context) { var ddbService = TestUtilities.GetServices().GetService(); var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService!, new MockMetricService()), new TestContext { - UserID = context.User.ID, + UserID = context.User.ID }); Assert.NotNull(ddbService); diff --git a/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs b/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs index 8a0f55f..c2cb16b 100644 --- a/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs +++ b/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Moq; +using PaxAndromeda.Instar; using PaxAndromeda.Instar.Preconditions; using Xunit; @@ -24,7 +25,7 @@ public async Task CheckRequirementsAsync_ShouldReturnFalse_WithBadConfig() var context = TestUtilities.SetupContext(new TestContext { - UserRoles = [new(793607635608928257)] + UserRoles = [new Snowflake(793607635608928257)] }); // Act @@ -58,7 +59,7 @@ public async Task CheckRequirementsAsync_ShouldReturnSuccessful_WithValidUser() var context = TestUtilities.SetupContext(new TestContext { - UserRoles = [new(793607635608928257)] + UserRoles = [new Snowflake(793607635608928257)] }); // Act @@ -76,7 +77,7 @@ public async Task CheckRequirementsAsync_ShouldReturnFailure_WithNonStaffUser() var context = TestUtilities.SetupContext(new TestContext { - UserRoles = [new()] + UserRoles = [new Snowflake()] }); // Act diff --git a/InstarBot/Commands/CheckEligibilityCommand.cs b/InstarBot/Commands/CheckEligibilityCommand.cs index b8d97f0..4127f0d 100644 --- a/InstarBot/Commands/CheckEligibilityCommand.cs +++ b/InstarBot/Commands/CheckEligibilityCommand.cs @@ -91,7 +91,7 @@ private async Task BuildMissingItemsText(MembershipEligibility eligibili foreach (var roleGroup in config.AutoMemberConfig.RequiredRoles) { if (user.RoleIds.Intersect(roleGroup.Roles.Select(n => n.ID)).Any()) continue; - var prefix = "aeiouAEIOU".IndexOf(roleGroup.GroupName[0]) >= 0 ? "an" : "a"; // grammar hack :) + var prefix = "aeiouAEIOU".Contains(roleGroup.GroupName[0]) ? "an" : "a"; // grammar hack :) missingItemsBuilder.AppendLine( $"- You are missing {prefix} {roleGroup.GroupName.ToLowerInvariant()} role."); } diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index 0648c45..6c3520e 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -112,7 +112,7 @@ private static void CurrentDomainOnUnhandledException(object sender, UnhandledEx Log.Fatal(e.ExceptionObject as Exception, "FATAL: Unhandled exception caught"); } - private static IServiceProvider ConfigureServices(IConfiguration config) + private static ServiceProvider ConfigureServices(IConfiguration config) { var services = new ServiceCollection(); diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index a7eb49b..28ce01e 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -279,7 +279,7 @@ public async Task CheckEligibility(IGuildUser user) if (!_introductionPosters.ContainsKey(user.Id)) eligibility |= MembershipEligibility.MissingIntroduction; - if (_recentMessages.TryGetValue(user.Id, out int messages) && messages < cfg.AutoMemberConfig.MinimumMessages) + if (_recentMessages.TryGetValue(user.Id, out var messages) && messages < cfg.AutoMemberConfig.MinimumMessages) eligibility |= MembershipEligibility.NotEnoughMessages; if (_punishedUsers.ContainsKey(user.Id)) @@ -306,7 +306,7 @@ private static bool CheckUserRequiredRoles(InstarDynamicConfiguration cfg, IGuil private Dictionary GetMessagesSent() { - var map = this._messageCache + var map = _messageCache .Cast>() // Cast to access LINQ extensions .Select(entry => entry.Value) .GroupBy(properties => properties.UserID) diff --git a/InstarBot/Services/CloudwatchMetricService.cs b/InstarBot/Services/CloudwatchMetricService.cs index 724d61a..165c1dd 100644 --- a/InstarBot/Services/CloudwatchMetricService.cs +++ b/InstarBot/Services/CloudwatchMetricService.cs @@ -29,7 +29,7 @@ public async Task Emit(Metric metric, double value) var datum = new MetricDatum { - MetricName = nameAttr is not null ? nameAttr.Name : Enum.GetName(typeof(Metric), metric), + MetricName = nameAttr is not null ? nameAttr.Name : Enum.GetName(metric), Value = value }; diff --git a/InstarBot/Services/GaiusAPIService.cs b/InstarBot/Services/GaiusAPIService.cs index e1db0b0..a7a0f86 100644 --- a/InstarBot/Services/GaiusAPIService.cs +++ b/InstarBot/Services/GaiusAPIService.cs @@ -112,7 +112,7 @@ public async Task> GetCaselogsAfter(DateTime dt) return ParseCaselogs(result); } - internal static IEnumerable ParseCaselogs(string response) + private static IEnumerable ParseCaselogs(string response) { // Remove any instances of "totalCases" while (response.Contains("\"totalcases\":", StringComparison.OrdinalIgnoreCase)) diff --git a/InstarBot/Utilities.cs b/InstarBot/Utilities.cs index a02b512..743b446 100644 --- a/InstarBot/Utilities.cs +++ b/InstarBot/Utilities.cs @@ -19,11 +19,7 @@ public static class Utilities { var type = enumVal.GetType(); var membersInfo = type.GetMember(enumVal.ToString()); - if (membersInfo.Length == 0) - return null; - - var attr = membersInfo[0].GetCustomAttribute(typeof(T), false); - return attr as T; + return membersInfo.Length == 0 ? null : membersInfo[0].GetCustomAttribute(false); } public static string Remove(this string text, Range range) From bbf4909092c24f0a687f3a05266ab2d24fa3e2e4 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sat, 22 Nov 2025 19:20:51 -0800 Subject: [PATCH 18/53] Added new functionality: - Updated Instar to .NET 10 / C# 14 - Updated backend database structure - Added - Updated /checkeligibility command - Fixed a bug where /checkeligibility fails if user is AMHed - New /eligibility command for staff to check the eligibility of members - AMH commands and related supporting code --- .../InstarBot.Tests.Common.csproj | 11 +- .../Models/TestGuildUser.cs | 6 + .../Services/MockAutoMemberSystem.cs | 19 ++ .../Services/MockDiscordService.cs | 35 ++- .../Services/MockInstarDDBService.cs | 66 +++-- InstarBot.Tests.Common/TestUtilities.cs | 19 +- InstarBot.Tests.Integration/Assembly.cs | 1 + .../InstarBot.Tests.Integration.csproj | 12 +- .../Interactions/AutoMemberSystemTests.cs | 137 +++++++-- .../CheckEligibilityCommandTests.cs | 169 +++++++++++ .../Interactions/PageCommandTests.cs | 2 +- .../Interactions/ReportUserTests.cs | 2 +- .../Interactions/SetBirthdayCommandTests.cs | 11 +- InstarBot.Tests.Unit/Assembly.cs | 3 + .../DynamoModels/EventListTests.cs | 123 ++++++++ .../InstarBot.Tests.Unit.csproj | 12 +- InstarBot.Tests.Unit/UtilitiesTests.cs | 22 ++ InstarBot/BadStateException.cs | 24 ++ InstarBot/Commands/AutoMemberHoldCommand.cs | 121 ++++++++ InstarBot/Commands/CheckEligibilityCommand.cs | 230 +++++++++++---- InstarBot/Commands/SetBirthdayCommand.cs | 26 +- .../TriggerAutoMemberSystemCommand.cs | 2 +- InstarBot/Config/Instar.conf.schema.json | 14 + .../ArbitraryDynamoDBTypeConverter.cs | 243 ++++++++++++++++ InstarBot/DynamoModels/EventList.cs | 72 +++++ InstarBot/DynamoModels/InstarDatabaseEntry.cs | 26 ++ InstarBot/DynamoModels/InstarUserData.cs | 267 ++++++++++++++++++ InstarBot/InstarBot.csproj | 40 +-- InstarBot/MembershipEligibility.cs | 18 +- InstarBot/Metrics/Metric.cs | 12 +- InstarBot/Modals/UserUpdatedEventArgs.cs | 19 ++ InstarBot/Program.cs | 6 +- InstarBot/Services/AutoMemberSystem.cs | 227 +++++++++++---- InstarBot/Services/CloudwatchMetricService.cs | 3 +- InstarBot/Services/DiscordService.cs | 39 ++- InstarBot/Services/IAutoMemberSystem.cs | 28 ++ InstarBot/Services/IDiscordService.cs | 4 +- InstarBot/Services/IInstarDDBService.cs | 42 ++- InstarBot/Services/InstarDDBService.cs | 119 ++------ InstarBot/Utilities.cs | 141 +++++++-- 40 files changed, 1998 insertions(+), 375 deletions(-) create mode 100644 InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs create mode 100644 InstarBot.Tests.Integration/Assembly.cs create mode 100644 InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs create mode 100644 InstarBot.Tests.Unit/Assembly.cs create mode 100644 InstarBot.Tests.Unit/DynamoModels/EventListTests.cs create mode 100644 InstarBot.Tests.Unit/UtilitiesTests.cs create mode 100644 InstarBot/BadStateException.cs create mode 100644 InstarBot/Commands/AutoMemberHoldCommand.cs create mode 100644 InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs create mode 100644 InstarBot/DynamoModels/EventList.cs create mode 100644 InstarBot/DynamoModels/InstarDatabaseEntry.cs create mode 100644 InstarBot/DynamoModels/InstarUserData.cs create mode 100644 InstarBot/Modals/UserUpdatedEventArgs.cs create mode 100644 InstarBot/Services/IAutoMemberSystem.cs diff --git a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj index f16bfad..a57be9c 100644 --- a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj +++ b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable InstarBot.Tests @@ -10,11 +10,12 @@ - - + + - - + + + diff --git a/InstarBot.Tests.Common/Models/TestGuildUser.cs b/InstarBot.Tests.Common/Models/TestGuildUser.cs index 7e1a21f..708620b 100644 --- a/InstarBot.Tests.Common/Models/TestGuildUser.cs +++ b/InstarBot.Tests.Common/Models/TestGuildUser.cs @@ -185,4 +185,10 @@ public IReadOnlyCollection RoleIds public string AvatarDecorationHash => throw new NotImplementedException(); public ulong? AvatarDecorationSkuId => throw new NotImplementedException(); + public PrimaryGuild? PrimaryGuild { get; } + + public TestGuildUser Clone() + { + return (TestGuildUser) MemberwiseClone(); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs b/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs new file mode 100644 index 0000000..4a6c2f0 --- /dev/null +++ b/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs @@ -0,0 +1,19 @@ +using Discord; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Tests.Services; + +public class MockAutoMemberSystem : IAutoMemberSystem +{ + public Task RunAsync() + { + throw new NotImplementedException(); + } + + public virtual MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IGuildUser user) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockDiscordService.cs b/InstarBot.Tests.Common/Services/MockDiscordService.cs index b1af3a7..da1ca6e 100644 --- a/InstarBot.Tests.Common/Services/MockDiscordService.cs +++ b/InstarBot.Tests.Common/Services/MockDiscordService.cs @@ -2,6 +2,7 @@ using InstarBot.Tests.Models; using JetBrains.Annotations; using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Services; namespace InstarBot.Tests.Services; @@ -10,16 +11,23 @@ public sealed class MockDiscordService : IDiscordService { private readonly IInstarGuild _guild; private readonly AsyncEvent _userJoinedEvent = new(); - private readonly AsyncEvent _messageReceivedEvent = new(); - private readonly AsyncEvent _messageDeletedEvent = new(); + private readonly AsyncEvent _userUpdatedEvent = new(); + private readonly AsyncEvent _messageReceivedEvent = new(); + private readonly AsyncEvent _messageDeletedEvent = new(); - public event Func UserJoined + public event Func UserJoined { add => _userJoinedEvent.Add(value); remove => _userJoinedEvent.Remove(value); - } + } + + public event Func UserUpdated + { + add => _userUpdatedEvent.Add(value); + remove => _userUpdatedEvent.Remove(value); + } - public event Func MessageReceived + public event Func MessageReceived { add => _messageReceivedEvent.Add(value); remove => _messageReceivedEvent.Remove(value); @@ -62,14 +70,19 @@ public async IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime await foreach (var messageList in channel.GetMessagesAsync()) foreach (var message in messageList) yield return message; - } + } - public async Task TriggerUserJoined(IGuildUser user) - { - await _userJoinedEvent.Invoke(user); - } + public async Task TriggerUserJoined(IGuildUser user) + { + await _userJoinedEvent.Invoke(user); + } + + public async Task TriggerUserUpdated(UserUpdatedEventArgs args) + { + await _userUpdatedEvent.Invoke(args); + } - [UsedImplicitly] + [UsedImplicitly] public async Task TriggerMessageReceived(IMessage message) { await _messageReceivedEvent.Invoke(message); diff --git a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs index f9d9676..cb6aecd 100644 --- a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs +++ b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs @@ -1,65 +1,71 @@ -using InstarBot.Tests.Models; +using Amazon.DynamoDBv2.DataModel; +using Discord; +using Moq; using PaxAndromeda.Instar; +using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; namespace InstarBot.Tests.Services; +/// +/// A mock implementation of the IInstarDDBService interface for unit testing purposes. +/// This class provides an in-memory storage mechanism to simulate DynamoDB operations. +/// public sealed class MockInstarDDBService : IInstarDDBService { - private readonly Dictionary _localData; + private readonly Dictionary _localData; public MockInstarDDBService() { - _localData = new Dictionary(); + _localData = new Dictionary(); } - public MockInstarDDBService(IEnumerable preload) + public MockInstarDDBService(IEnumerable preload) { - _localData = preload.ToDictionary(n => n.Snowflake, n => n); + _localData = preload.ToDictionary(n => n.UserID!, n => n); } - public Task UpdateUserBirthday(Snowflake snowflake, DateTime birthday) + public void Register(InstarUserData data) { - _localData.TryAdd(snowflake, new UserDatabaseInformation(snowflake)); - _localData[snowflake].Birthday = birthday; - - return Task.FromResult(true); + _localData.TryAdd(data.UserID!, data); } - public Task UpdateUserJoinDate(Snowflake snowflake, DateTime joinDate) + public Task?> GetUserAsync(Snowflake snowflake) { - _localData.TryAdd(snowflake, new UserDatabaseInformation(snowflake)); - _localData[snowflake].JoinDate = joinDate; + if (!_localData.TryGetValue(snowflake, out var data)) + throw new InvalidOperationException("User not found."); + + var ddbContextMock = new Mock(); - return Task.FromResult(true); + return Task.FromResult(new InstarDatabaseEntry(ddbContextMock.Object, data))!; } - public Task UpdateUserMembership(Snowflake snowflake, bool membershipGranted) + public Task> GetOrCreateUserAsync(IGuildUser user) { - _localData.TryAdd(snowflake, new UserDatabaseInformation(snowflake)); - _localData[snowflake].GrantedMembership = membershipGranted; + if (!_localData.TryGetValue(user.Id, out var data)) + data = InstarUserData.CreateFrom(user); + + var ddbContextMock = new Mock(); - return Task.FromResult(true); + return Task.FromResult(new InstarDatabaseEntry(ddbContextMock.Object, data)); } - public Task GetUserBirthday(Snowflake snowflake) + public Task>> GetBatchUsersAsync(IEnumerable snowflakes) { - return !_localData.TryGetValue(snowflake, out var value) - ? Task.FromResult(null) - : Task.FromResult(value.Birthday); + return Task.FromResult(GetLocalUsers(snowflakes) + .Select(n => new InstarDatabaseEntry(new Mock().Object, n)).ToList()); } - public Task GetUserJoinDate(Snowflake snowflake) + private IEnumerable GetLocalUsers(IEnumerable snowflakes) { - return !_localData.TryGetValue(snowflake, out var value) - ? Task.FromResult(null) - : Task.FromResult(value.JoinDate); + foreach (var snowflake in snowflakes) + if (_localData.TryGetValue(snowflake, out var data)) + yield return data; } - public Task GetUserMembership(Snowflake snowflake) + public Task CreateUserAsync(InstarUserData data) { - return !_localData.TryGetValue(snowflake, out var value) - ? Task.FromResult(null) - : Task.FromResult(value.GrantedMembership); + _localData.TryAdd(data.UserID!, data); + return Task.CompletedTask; } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/TestUtilities.cs b/InstarBot.Tests.Common/TestUtilities.cs index 153ac47..ad3799f 100644 --- a/InstarBot.Tests.Common/TestUtilities.cs +++ b/InstarBot.Tests.Common/TestUtilities.cs @@ -12,6 +12,8 @@ using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.Services; +using Serilog; +using Serilog.Events; namespace InstarBot.Tests; @@ -77,7 +79,7 @@ public static void VerifyMessage(Mock command, string message, bool epheme "RespondAsync", Times.Once(), message, ItExpr.IsAny(), false, ephemeral, ItExpr.IsAny(), ItExpr.IsAny(), - ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } public static IDiscordService SetupDiscordService(TestContext context = null!) @@ -110,7 +112,7 @@ public static Mock SetupCommandMock(Expression> newExpression, Tes public static Mock SetupCommandMock(TestContext context = null!) where T : BaseCommand { - // Quick check: Do we have a constructor that takes IConfiguration? + // Quick check: Do we have a constructor that takes IConfiguration? var iConfigCtor = typeof(T).GetConstructors() .Any(n => n.GetParameters().Any(info => info.ParameterType == typeof(IConfiguration))); @@ -131,7 +133,8 @@ private static void ConfigureCommandMock(Mock mock, TestContext? context) It.IsAny(), ItExpr.IsNull(), ItExpr.IsNull(), ItExpr.IsNull(), ItExpr.IsNull(), - ItExpr.IsNull()) + ItExpr.IsNull(), + ItExpr.IsNull()) .Returns(Task.CompletedTask); } @@ -237,4 +240,14 @@ public static async IAsyncEnumerable GetTeams(PageTarget pageTarget) yield return value; } } + + public static void SetupLogging() + { + Log.Logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.Is(LogEventLevel.Verbose) + .WriteTo.Console() + .CreateLogger(); + Log.Warning("Logging is enabled for this unit test."); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Assembly.cs b/InstarBot.Tests.Integration/Assembly.cs new file mode 100644 index 0000000..4941ec7 --- /dev/null +++ b/InstarBot.Tests.Integration/Assembly.cs @@ -0,0 +1 @@ +[assembly: Parallelize] \ No newline at end of file diff --git a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj index 2041b84..e77e0aa 100644 --- a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj +++ b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj @@ -1,23 +1,23 @@  - net9.0 + net10.0 enable enable - + - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs index 9d282d2..7f493d2 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs @@ -3,7 +3,9 @@ using InstarBot.Tests.Services; using PaxAndromeda.Instar; using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Gaius; +using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Services; using Xunit; @@ -18,7 +20,7 @@ public static class AutoMemberSystemTests private static readonly Snowflake SheHer = new(796578609535647765); private static readonly Snowflake AutoMemberHold = new(966434762032054282); - private static async Task SetupTest(AutoMemberSystemContext scenarioContext) + private static AutoMemberSystem SetupTest(AutoMemberSystemContext scenarioContext) { var testContext = scenarioContext.TestContext; @@ -38,19 +40,22 @@ private static async Task SetupTest(AutoMemberSystemContext sc var amsConfig = scenarioContext.Config.AutoMemberConfig; var ddbService = new MockInstarDDBService(); - if (firstSeenTime > 0) - await ddbService.UpdateUserJoinDate(userId, DateTime.UtcNow - TimeSpan.FromHours(firstSeenTime)); - if (grantedMembershipBefore) - await ddbService.UpdateUserMembership(userId, grantedMembershipBefore); - testContext.AddRoles(roles); + var user = new TestGuildUser + { + Id = userId, + Username = "username", + JoinedAt = DateTimeOffset.Now - TimeSpan.FromHours(relativeJoinTime), + RoleIds = roles.Select(n => n.ID).ToList().AsReadOnly() + }; - var user = new TestGuildUser - { - Id = userId, - JoinedAt = DateTimeOffset.Now - TimeSpan.FromHours(relativeJoinTime), - RoleIds = roles.Select(n => n.ID).ToList().AsReadOnly() - }; + var userData = InstarUserData.CreateFrom(user); + userData.Position = grantedMembershipBefore ? InstarUserPosition.Member : InstarUserPosition.NewMember; + + if (!scenarioContext.SuppressDDBEntry) + ddbService.Register(userData); + + testContext.AddRoles(roles); testContext.GuildUsers.Add(user); @@ -69,6 +74,7 @@ private static async Task SetupTest(AutoMemberSystemContext sc var ams = new AutoMemberSystem(config, discordService, gaiusApiService, ddbService, new MockMetricService()); scenarioContext.User = user; + scenarioContext.DynamoService = ddbService; return ams; } @@ -85,7 +91,7 @@ public static async Task AutoMemberSystem_EligibleUser_ShouldBeGrantedMembership .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -106,7 +112,7 @@ public static async Task AutoMemberSystem_EligibleUserWithAMH_ShouldNotBeGranted .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -127,7 +133,7 @@ public static async Task AutoMemberSystem_NewUser_ShouldNotBeGrantedMembership() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -147,7 +153,7 @@ public static async Task AutoMemberSystem_InactiveUser_ShouldNotBeGrantedMembers .WithMessages(10) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -167,7 +173,7 @@ public static async Task AutoMemberSystem_Member_ShouldNotBeChanged() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -186,7 +192,7 @@ public static async Task AutoMemberSystem_NoIntroduction_ShouldNotBeGrantedMembe .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -206,7 +212,7 @@ public static async Task AutoMemberSystem_NoAgeRole_ShouldNotBeGrantedMembership .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -226,7 +232,7 @@ public static async Task AutoMemberSystem_NoGenderRole_ShouldNotBeGrantedMembers .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -246,7 +252,7 @@ public static async Task AutoMemberSystem_NoPronounRole_ShouldNotBeGrantedMember .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -267,7 +273,7 @@ public static async Task AutoMemberSystem_UserWithGaiusWarning_ShouldNotBeGrante .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -288,7 +294,7 @@ public static async Task AutoMemberSystem_UserWithGaiusCaselog_ShouldNotBeGrante .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -309,7 +315,7 @@ public static async Task AutoMemberSystem_GaiusIsUnavailable_ShouldBeGrantedMemb .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var ams = SetupTest(context); // Act await ams.RunAsync(); @@ -331,7 +337,7 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe .WithMessages(100) .Build(); - await SetupTest(context); + SetupTest(context); // Act var service = context.DiscordService as MockDiscordService; @@ -346,6 +352,68 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe context.AssertMember(); } + [Fact(DisplayName = "The Dynamo record for a user should be updated when the user's username changes")] + public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflectedInDynamo() + { + // Arrange + const string NewUsername = "fred"; + + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(1)) + .FirstJoined(TimeSpan.FromDays(7)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .HasBeenGrantedMembershipBefore() + .WithMessages(100) + .Build(); + + SetupTest(context); + + // Make sure the user is in the database + context.DynamoService.Should().NotBeNull(because: "Test is invalid if DynamoService is not set"); + await context.DynamoService.CreateUserAsync(InstarUserData.CreateFrom(context.User!)); + + // Act + MockDiscordService mds = (MockDiscordService) context.DiscordService!; + + var newUser = context.User!.Clone(); + newUser.Username = NewUsername; + + await mds.TriggerUserUpdated(new UserUpdatedEventArgs(context.UserID, context.User, newUser)); + + // Assert + var ddbUser = await context.DynamoService.GetUserAsync(context.UserID); + + ddbUser.Should().NotBeNull(); + ddbUser.Data.Username.Should().Be(NewUsername); + + ddbUser.Data.Usernames.Should().NotBeNull(); + ddbUser.Data.Usernames.Count.Should().Be(2); + ddbUser.Data.Usernames.Should().Contain(n => n.Data != null && n.Data.Equals(NewUsername, StringComparison.Ordinal)); + } + + [Fact(DisplayName = "A user should be created in DynamoDB if they're eligible for membership but missing in DDB")] + public static async Task AutoMemberSystem_MemberEligibleButMissingInDDB_ShouldBeCreatedAndGrantedMembership() + { + + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .WithMessages(100) + .SuppressDDBEntry() + .Build(); + + var ams = SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertMember(); + } + private record AutoMemberSystemContext( Snowflake UserID, int HoursSinceJoined, @@ -354,6 +422,7 @@ private record AutoMemberSystemContext( int MessagesLast24Hours, int FirstJoinTime, bool GrantedMembershipBefore, + bool SuppressDDBEntry, TestContext TestContext, InstarDynamicConfiguration Config) { @@ -361,6 +430,7 @@ private record AutoMemberSystemContext( public IDiscordService? DiscordService { get; set; } public TestGuildUser? User { get; set; } + public IInstarDDBService? DynamoService { get ; set ; } public void AssertMember() { @@ -394,8 +464,10 @@ private class AutoMemberSystemContextBuilder private bool _gaiusWarned; private int _firstJoinTime; private bool _grantedMembershipBefore; + private bool _suppressDDB; + - public AutoMemberSystemContextBuilder Joined(TimeSpan timeAgo) + public AutoMemberSystemContextBuilder Joined(TimeSpan timeAgo) { _hoursSinceJoined = (int) Math.Round(timeAgo.TotalHours); return this; @@ -446,9 +518,15 @@ public AutoMemberSystemContextBuilder HasBeenGrantedMembershipBefore() { _grantedMembershipBefore = true; return this; - } + } + + public AutoMemberSystemContextBuilder SuppressDDBEntry() + { + _suppressDDB = true; + return this; + } - public async Task Build() + public async Task Build() { var config = await TestUtilities.GetDynamicConfiguration().GetConfig(); @@ -487,7 +565,8 @@ public async Task Build() _messagesLast24Hours, _firstJoinTime, _grantedMembershipBefore, - testContext, + _suppressDDB, + testContext, config); } } diff --git a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs new file mode 100644 index 0000000..dde6b68 --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs @@ -0,0 +1,169 @@ +using Discord; +using InstarBot.Tests.Services; +using Moq; +using Moq.Protected; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.ConfigModels; +using Xunit; + +namespace InstarBot.Tests.Integration.Interactions; + +public static class CheckEligibilityCommandTests +{ + + private static Mock SetupCommandMock(CheckEligibilityCommandTestContext context) + { + var mockAMS = new Mock(); + mockAMS.Setup(n => n.CheckEligibility(It.IsAny(), It.IsAny())).Returns(context.Eligibility); + + + List userRoles = [ + // from Instar.dynamic.test.debug.conf.json: member and new member role, respectively + context.IsMember ? 793611808372031499ul : 796052052433698817ul + ]; + + if (context.IsAMH) + { + // from Instar.dynamic.test.debug.conf.json + userRoles.Add(966434762032054282); + } + + var commandMock = TestUtilities.SetupCommandMock( + () => new CheckEligibilityCommand(TestUtilities.GetDynamicConfiguration(), mockAMS.Object, new MockMetricService()), + new TestContext + { + UserRoles = userRoles + }); + + return commandMock; + } + + private static void VerifyResponse(Mock command, string expectedString) + { + command.Protected().Verify( + "RespondAsync", + Times.Once(), + expectedString, // text + ItExpr.IsNull(), // embeds + false, // isTTS + true, // ephemeral + ItExpr.IsNull(), // allowedMentions + ItExpr.IsNull(), // options + ItExpr.IsNull(), // components + ItExpr.IsAny(), // embed + ItExpr.IsAny(), // pollProperties + ItExpr.IsAny() // messageFlags + ); + } + + private static void VerifyResponseEmbed(Mock command, CheckEligibilityCommandTestContext ctx) + { + // Little more convoluted to verify embed content + command.Protected().Verify( + "RespondAsync", + Times.Once(), + ItExpr.IsNull(), // text + ItExpr.IsNull(), // embeds + false, // isTTS + true, // ephemeral + ItExpr.IsNull(), // allowedMentions + ItExpr.IsNull(), // options + ItExpr.IsNull(), // components + ItExpr.Is(e => e.Description.Contains(ctx.DescriptionPattern) && e.Description.Contains(ctx.DescriptionPattern2) && + e.Fields.Any(n => n.Value.Contains(ctx.MissingItemPattern)) + ), // embed + ItExpr.IsNull(), // pollProperties + ItExpr.IsAny() // messageFlags + ); + } + + [Fact] + public static async Task CheckEligibilityCommand_WithExistingMember_ShouldEmitValidMessage() + { + // Arrange + var ctx = new CheckEligibilityCommandTestContext(false, true, MembershipEligibility.Eligible); + var mock = SetupCommandMock(ctx); + + // Act + await mock.Object.CheckEligibility(); + + // Assert + VerifyResponse(mock, "You are already a member!"); + } + + [Fact] + public static async Task CheckEligibilityCommand_WithNewMemberAMH_ShouldEmitValidMessage() + { + // Arrange + var ctx = new CheckEligibilityCommandTestContext( + true, + false, + MembershipEligibility.Eligible, + "Your membership is currently on hold", + MissingItemPattern: "The staff will override an administrative hold"); + + var mock = SetupCommandMock(ctx); + + // Act + await mock.Object.CheckEligibility(); + + // Assert + VerifyResponseEmbed(mock, ctx); + } + + [Theory] + [InlineData(MembershipEligibility.MissingRoles, "You are missing an age role.")] + [InlineData(MembershipEligibility.MissingIntroduction, "You have not posted an introduction in")] + [InlineData(MembershipEligibility.TooYoung, "You have not been on the server for")] + [InlineData(MembershipEligibility.PunishmentReceived, "You have received a warning or moderator action.")] + [InlineData(MembershipEligibility.NotEnoughMessages, "messages in the past")] + public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMessage(MembershipEligibility eligibility, string pattern) + { + // Arrange + string sectionHeader = eligibility switch + { + MembershipEligibility.MissingRoles => "Roles", + MembershipEligibility.MissingIntroduction => "Introduction", + MembershipEligibility.TooYoung => "Join Age", + MembershipEligibility.PunishmentReceived => "Mod Actions", + MembershipEligibility.NotEnoughMessages => "Messages", + _ => "" + }; + + // Cheeky way to get another section header + string anotherSectionHeader = (MembershipEligibility) ((int)eligibility << 1) switch + { + MembershipEligibility.MissingRoles => "Roles", + MembershipEligibility.MissingIntroduction => "Introduction", + MembershipEligibility.TooYoung => "Join Age", + MembershipEligibility.PunishmentReceived => "Mod Actions", + MembershipEligibility.NotEnoughMessages => "Messages", + _ => "Roles" + }; + + var ctx = new CheckEligibilityCommandTestContext( + false, + false, + MembershipEligibility.NotEligible | eligibility, + $":x: **{sectionHeader}**", + $":white_check_mark: **{anotherSectionHeader}", + pattern); + + var mock = SetupCommandMock(ctx); + + // Act + await mock.Object.CheckEligibility(); + + // Assert + VerifyResponseEmbed(mock, ctx); + } + + private record CheckEligibilityCommandTestContext( + bool IsAMH, + bool IsMember, + MembershipEligibility Eligibility, + string DescriptionPattern = "", + string DescriptionPattern2 = "", + string MissingItemPattern = ""); +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index 8ca2607..dbdb3b4 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -68,7 +68,7 @@ await TestUtilities.GetTeams(PageTarget.All).Select(n => Snowflake.GetMention(() "RespondAsync", Times.Once(), expectedString, ItExpr.IsNull(), false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny()); + ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } [Fact(DisplayName = "User should be able to page when authorized")] diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs index 53d3acd..eed8d1a 100644 --- a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -39,7 +39,7 @@ await command.Object.ModalResponse(new ReportMessageModal It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())); - Assert.NotNull(context.ResultEmbed); + context.ResultEmbed.Should().NotBeNull(); var embed = context.ResultEmbed; embed.Author.Should().NotBeNull(); diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs index 4812d95..f97203a 100644 --- a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -4,6 +4,7 @@ using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; using Xunit; @@ -13,13 +14,17 @@ public static class SetBirthdayCommandTests { private static (IInstarDDBService, Mock) SetupMocks(SetBirthdayContext context) { + TestUtilities.SetupLogging(); + var ddbService = TestUtilities.GetServices().GetService(); var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService!, new MockMetricService()), new TestContext { UserID = context.User.ID }); + + ((MockInstarDDBService) ddbService!).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); - Assert.NotNull(ddbService); + ddbService.Should().NotBeNull(); return (ddbService, cmd); } @@ -44,7 +49,9 @@ public static async Task SetBirthdayCommand_WithValidDate_ShouldSetCorrectly(int // Assert var date = context.ToDateTime(); - (await ddb.GetUserBirthday(context.User.ID)).Should().Be(date.UtcDateTime); + + var ddbUser = await ddb.GetUserAsync(context.User.ID); + ddbUser!.Data.Birthday.Should().Be(date.UtcDateTime); TestUtilities.VerifyMessage(cmd, $"Your birthday was set to {date.DateTime:dddd, MMMM d, yyy}.", true); } diff --git a/InstarBot.Tests.Unit/Assembly.cs b/InstarBot.Tests.Unit/Assembly.cs new file mode 100644 index 0000000..5d68f78 --- /dev/null +++ b/InstarBot.Tests.Unit/Assembly.cs @@ -0,0 +1,3 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[assembly: Parallelize] \ No newline at end of file diff --git a/InstarBot.Tests.Unit/DynamoModels/EventListTests.cs b/InstarBot.Tests.Unit/DynamoModels/EventListTests.cs new file mode 100644 index 0000000..f041a8c --- /dev/null +++ b/InstarBot.Tests.Unit/DynamoModels/EventListTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using PaxAndromeda.Instar.DynamoModels; +using Serilog; +using Xunit; + +namespace InstarBot.Tests.DynamoModels; + +public class EventListTests +{ + [Fact] + public void Add_SequentialItems_ShouldBeAddedInOrder() + { + TestUtilities.SetupLogging(); + + // Arrange/Act + //var list = new EventList>(); + var list = new SortedSet>(Comparer>.Create((x, y) => x.Date.CompareTo(y.Date))) + { + new(1, DateTime.Now - TimeSpan.FromMinutes(2)), + new(2, DateTime.Now - TimeSpan.FromMinutes(1)), + new(3, DateTime.Now) + }; + + // Assert + var collapsedList = list.ToList(); + collapsedList[0].Value.Should().Be(1); + collapsedList[1].Value.Should().Be(2); + collapsedList[2].Value.Should().Be(3); + } + + [Fact] + public void Add_IntermediateItem_ShouldBeAddedInMiddle() + { + TestUtilities.SetupLogging(); + + // Arrange + var list = new EventList> + { + new(1, DateTime.Now - TimeSpan.FromMinutes(2)), + new(3, DateTime.Now) + }; + + list.First().Value.Should().Be(1); + + // Act + Log.Information("Inserting entry 2 at the middle of the list."); + list.Add(new TestEntry(2, DateTime.Now - TimeSpan.FromMinutes(1))); + + // Assert + var collapsedList = list.ToList(); + collapsedList[0].Value.Should().Be(1); + collapsedList[1].Value.Should().Be(2); // this should be in the middle + collapsedList[2].Value.Should().Be(3); + } + + [Fact] + public void Add_LastItem_ShouldBeAddedInMiddle() + { + TestUtilities.SetupLogging(); + + // Arrange + var list = new EventList> + { + new(3, DateTime.Now), + new(2, DateTime.Now - TimeSpan.FromMinutes(1)) + }; + + list.Latest().Value.Should().Be(3); + list.First().Value.Should().Be(2); + + // Act + Log.Information("Inserting entry 1 at the end of the list."); + list.Add(new TestEntry(1, DateTime.Now - TimeSpan.FromMinutes(2))); + + // Assert + var collapsedList = list.ToList(); + collapsedList[0].Value.Should().Be(1); + collapsedList[1].Value.Should().Be(2); // this should be in the middle + collapsedList[2].Value.Should().Be(3); + } + + [Fact] + public void Add_RandomItems_ShouldBeChronological() + { + TestUtilities.SetupLogging(); + + // Arrange + var items = new List>(); + for (var i = 0; i < 100; i++) + items.Add(new TestEntry(i, DateTime.Now - TimeSpan.FromMinutes(i))); + + // Run a Fisher-Yates shuffle: + var rng = new Random(); + var n = items.Count; + while (n > 1) + { + n--; + var k = rng.Next(n + 1); + (items[k], items[n]) = (items[n], items[k]); + } + + // Arrange + var list = new EventList>(items); + + // Act + Log.Information("Inserting entry 1 at the end of the list."); + list.Add(new TestEntry(1, DateTime.Now - TimeSpan.FromMinutes(2))); + + // Assert + var collapsedList = list.ToList(); + + collapsedList.Should().BeInDescendingOrder(d => d.Value); + } + + private class TestEntry(T value, DateTime date) : ITimedEvent + { + public DateTime Date { get; set; } = date; + public T Value { get; set; } = value; + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj index dd7af44..73c5914 100644 --- a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj +++ b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable false @@ -11,13 +11,13 @@ - - + + - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/InstarBot.Tests.Unit/UtilitiesTests.cs b/InstarBot.Tests.Unit/UtilitiesTests.cs new file mode 100644 index 0000000..d15c70c --- /dev/null +++ b/InstarBot.Tests.Unit/UtilitiesTests.cs @@ -0,0 +1,22 @@ +using FluentAssertions; +using PaxAndromeda.Instar; +using Xunit; + +namespace InstarBot.Tests; + +public class UtilitiesTests +{ + [Theory] + [InlineData("OWNER", "Owner")] + [InlineData("ADMIN", "Admin")] + [InlineData("MODERATOR", "Moderator")] + [InlineData("SENIOR_HELPER", "SeniorHelper")] + [InlineData("HELPER", "Helper")] + [InlineData("COMMUNITY_MANAGER", "CommunityManager")] + [InlineData("MEMBER", "Member")] + [InlineData("NEW_MEMBER", "NewMember")] + public void ScreamingToSnakeCase_ShouldProduceValidSnakeCase(string input, string expected) + { + Utilities.ScreamingToPascalCase(input).Should().Be(expected); + } +} \ No newline at end of file diff --git a/InstarBot/BadStateException.cs b/InstarBot/BadStateException.cs new file mode 100644 index 0000000..903b5cf --- /dev/null +++ b/InstarBot/BadStateException.cs @@ -0,0 +1,24 @@ +namespace PaxAndromeda.Instar; + +/// +/// Represents an exception that is thrown when an operation encounters an invalid or unexpected state. +/// +/// +/// Use this exception to indicate that a method or process cannot proceed due to the current state of +/// the object or system. This exception is typically thrown when a precondition for an operation is not met, and +/// recovery may require correcting the state before retrying. +/// +public class BadStateException : Exception +{ + public BadStateException() + { + } + + public BadStateException(string message) : base(message) + { + } + + public BadStateException(string message, Exception inner) : base(message, inner) + { + } +} \ No newline at end of file diff --git a/InstarBot/Commands/AutoMemberHoldCommand.cs b/InstarBot/Commands/AutoMemberHoldCommand.cs new file mode 100644 index 0000000..5cc94e6 --- /dev/null +++ b/InstarBot/Commands/AutoMemberHoldCommand.cs @@ -0,0 +1,121 @@ +using System.Diagnostics.CodeAnalysis; +using Ardalis.GuardClauses; +using Discord; +using Discord.Interactions; +using JetBrains.Annotations; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Metrics; +using PaxAndromeda.Instar.Services; +using Serilog; + +namespace PaxAndromeda.Instar.Commands; + +[SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] +public class AutoMemberHoldCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfigService, IMetricService metricService) : BaseCommand +{ + [UsedImplicitly] + [SlashCommand("amh", "Withhold automatic membership grants to a user.")] + public async Task HoldMember( + [Summary("user", "The user to withhold automatic membership from.")] + IUser user, + [Summary("reason", "The reason for withholding automatic membership.")] + string reason + ) + { + Guard.Against.NullOrEmpty(reason); + Guard.Against.Null(Context.User); + Guard.Against.Null(user); + + var date = DateTime.UtcNow; + Snowflake modId = Context.User.Id; + + try + { + if (user is not IGuildUser guildUser) + { + await RespondAsync($"Error while attempting to withhold membership for <@{user.Id}>: User is not a guild member.", ephemeral: true); + return; + } + + var config = await dynamicConfigService.GetConfig(); + + if (guildUser.RoleIds.Contains(config.MemberRoleID)) + { + await RespondAsync($"Error while attempting to withhold membership for <@{user.Id}>: User is already a member.", ephemeral: true); + return; + } + + var dbUser = await ddbService.GetOrCreateUserAsync(guildUser); + + dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + ModeratorID = modId, + Reason = reason, + Date = date + }; + await dbUser.UpdateAsync(); + + // TODO: configurable duration? + await RespondAsync($"Membership for user <@{user.Id}> has been withheld. Staff will be notified in one week to review.", ephemeral: true); + } catch (Exception ex) + { + await metricService.Emit(Metric.AMS_AMHFailures, 1); + Log.Error(ex, "Failed to apply auto member hold requested by {ModID} to {UserID} for reason: \"{Reason}\"", modId.ID, user.Id, reason); + + try + { + // It is entirely possible that RespondAsync threw this error. + await RespondAsync($"Error while attempting to withhold membership for <@{user.Id}>: An unexpected error has occurred while configuring the AMH.", ephemeral: true); + } catch + { + // swallow the exception + } + } + } + + [UsedImplicitly] + [SlashCommand("removeamh", "Removes an auto member hold from the user.")] + public async Task UnholdMember( + [Summary("user", "The user to remove the auto member hold from.")] + IUser user + ) + { + Guard.Against.Null(Context.User); + Guard.Against.Null(user); + + if (user is not IGuildUser guildUser) + { + await RespondAsync($"Error while attempting to remove auto member hold for <@{user.Id}>: User is not a guild member.", ephemeral: true); + return; + } + + try + { + var dbUser = await ddbService.GetOrCreateUserAsync(guildUser); + if (dbUser.Data.AutoMemberHoldRecord is null) + { + await RespondAsync($"Error while attempting to remove auto member hold for <@{user.Id}>: User does not have an active auto member hold.", ephemeral: true); + return; + } + + dbUser.Data.AutoMemberHoldRecord = null; + await dbUser.UpdateAsync(); + + await RespondAsync($"Auto member hold for user <@{user.Id}> has been removed.", ephemeral: true); + } + catch (Exception ex) + { + await metricService.Emit(Metric.AMS_AMHFailures, 1); + Log.Error(ex, "Failed to remove auto member hold requested by {ModID} from {UserID}", Context.User.Id, user.Id); + + try + { + await RespondAsync($"Error while attempting to remove auto member hold for <@{user.Id}>: An unexpected error has occurred while removing the AMH.", ephemeral: true); + } + catch + { + // swallow the exception + } + } + } +} \ No newline at end of file diff --git a/InstarBot/Commands/CheckEligibilityCommand.cs b/InstarBot/Commands/CheckEligibilityCommand.cs index 4127f0d..2caac84 100644 --- a/InstarBot/Commands/CheckEligibilityCommand.cs +++ b/InstarBot/Commands/CheckEligibilityCommand.cs @@ -3,6 +3,7 @@ using Discord; using Discord.Interactions; using JetBrains.Annotations; +using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Serilog; @@ -12,7 +13,8 @@ namespace PaxAndromeda.Instar.Commands; [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] public class CheckEligibilityCommand( IDynamicConfigService dynamicConfig, - AutoMemberSystem autoMemberSystem, + IAutoMemberSystem autoMemberSystem, + IInstarDDBService ddbService, IMetricService metricService) : BaseCommand { @@ -39,43 +41,157 @@ public async Task CheckEligibility() await RespondAsync("You are already a member!", ephemeral: true); return; } - - var eligibility = await autoMemberSystem.CheckEligibility(Context.User); - - Log.Debug("Building response embed..."); - var fields = new List(); - if (eligibility.HasFlag(MembershipEligibility.NotEligible)) - { - fields.Add(new EmbedFieldBuilder() - .WithName("Missing Items") - .WithValue(await BuildMissingItemsText(eligibility, Context.User))); - } - var nextRun = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, - DateTimeOffset.UtcNow.Day, DateTimeOffset.UtcNow.Hour, 0, 0, TimeSpan.Zero) - + TimeSpan.FromHours(1); - var unixTime = nextRun.UtcTicks / 10000000-62135596800; // UTC ticks since year 0 to Unix Timestamp - - fields.Add(new EmbedFieldBuilder() - .WithName("Note") - .WithValue($"The Auto Member System will run . Membership eligibility is subject to change at the time of evaluation.")); + if (Context.User!.RoleIds.Contains(config.AutoMemberConfig.HoldRole)) + { + // User is on hold + await RespondAsync(embed: BuildAMHEmbed(), ephemeral: true); + return; + } - var builder = new EmbedBuilder() - // Set up all the basic stuff first - .WithCurrentTimestamp() - .WithColor(0x0c94e0) - .WithFooter(new EmbedFooterBuilder() - .WithText("Instar Auto Member System System") - .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) - .WithTitle("Membership Eligibility") - .WithDescription(BuildEligibilityText(eligibility)) - .WithFields(fields); + var embed = await BuildEligibilityEmbed(config, Context.User); Log.Debug("Responding..."); - await RespondAsync(embed: builder.Build(), ephemeral: true); + await RespondAsync(embed: embed, ephemeral: true); await metricService.Emit(Metric.AMS_EligibilityCheck, 1); } + [UsedImplicitly] + [SlashCommand("eligibility", "Checks the eligibility of another user on the server.")] + public async Task CheckOtherEligibility(IUser user) + { + if (user is not IGuildUser guildUser) + { + await RespondAsync($"Cannot check the eligibility for {user.Id} since they are not on this server.", ephemeral: true); + return; + } + + var cfg = await dynamicConfig.GetConfig(); + + var eligibility = autoMemberSystem.CheckEligibility(cfg, guildUser); + + // Let's build a fancy embed + var fields = new List(); + + bool hasAMH = false; + try + { + var dbUser = await ddbService.GetOrCreateUserAsync(guildUser); + if (dbUser.Data.AutoMemberHoldRecord is not null) + { + StringBuilder amhContextBuilder = new(); + amhContextBuilder.AppendLine($"**Mod:** <@{dbUser.Data.AutoMemberHoldRecord.ModeratorID.ID}>"); + amhContextBuilder.AppendLine("**Reason:**"); + amhContextBuilder.AppendLine($"```{dbUser.Data.AutoMemberHoldRecord.Reason}```"); + + amhContextBuilder.Append("**Date:** "); + + var secondsSinceEpoch = (long) Math.Floor((dbUser.Data.AutoMemberHoldRecord.Date - DateTime.UnixEpoch).TotalSeconds); + amhContextBuilder.Append($" ()"); + + fields.Add(new EmbedFieldBuilder() + .WithName(":warning: Auto Member Hold") + .WithValue(amhContextBuilder.ToString())); + hasAMH = true; + } + } catch (Exception ex) + { + Log.Error(ex, "Failed to retrieve user from DynamoDB while checking eligibility: {UserID}", user.Id); + + // Since we can't give exact details, we'll just note that there was an error + // and just confirm that the member's AMH status is unknown. + fields.Add(new EmbedFieldBuilder() + .WithName(":warning: Possible Auto Member Hold") + .WithValue("Instar encountered an error while attempting to retrieve AMH details. If the user is otherwise eligible but is not being granted membership, it is possible that they are AMHed. Please try again later.")); + } + + // Only add eligibility requirements if the user is not AMHed + if (!hasAMH) + { + fields.Add(new EmbedFieldBuilder() + .WithName(":small_blue_diamond: Requirements") + .WithValue(BuildEligibilityText(eligibility))); + } + + var builder = new EmbedBuilder() + .WithCurrentTimestamp() + .WithTitle("Membership Eligibility") + .WithFooter(new EmbedFooterBuilder() + .WithText("Instar Auto Member System") + .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) + .WithAuthor(new EmbedAuthorBuilder() + .WithName(user.Username) + .WithIconUrl(user.GetAvatarUrl())) + .WithDescription($"At this time, <@{user.Id}> is " + (!eligibility.HasFlag(MembershipEligibility.Eligible) ? "__not__ " : "") + " eligible for membership.") + .WithFields(fields); + + await RespondAsync(embed: builder.Build(), ephemeral: true); + } + + private static Embed BuildAMHEmbed() + { + var fields = new List + { + new EmbedFieldBuilder() + .WithName("Why?") + .WithValue("Your activity has been flagged as unusual by our system. This can happen for a variety of reasons, including antisocial behavior, repeated rule violations in a short period of time, or other behavior that is deemed disruptive."), + new EmbedFieldBuilder() + .WithName("What can I do?") + .WithValue("The staff will override an administrative hold in time when an evaluation of your behavior has been completed by the staff."), + new EmbedFieldBuilder() + .WithName("Should I contact Staff?") + .WithValue("No, the staff will not accelerate this process by request.") + }; + + var builder = new EmbedBuilder() + // Set up all the basic stuff first + .WithCurrentTimestamp() + .WithFooter(new EmbedFooterBuilder() + .WithText("Instar Auto Member System") + .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) + .WithTitle("Membership Eligibility") + .WithDescription("Your membership is currently on hold due to an administrative override. This means that you will not be granted the Member role automatically.") + .WithFields(fields); + + return builder.Build(); + } + + private async Task BuildEligibilityEmbed(InstarDynamicConfiguration config, IGuildUser user) + { + var eligibility = autoMemberSystem.CheckEligibility(config, user); + + Log.Debug("Building response embed..."); + var fields = new List(); + if (eligibility.HasFlag(MembershipEligibility.NotEligible)) + { + fields.Add(new EmbedFieldBuilder() + .WithName("Missing Items") + .WithValue(await BuildMissingItemsText(eligibility, user))); + } + + var nextRun = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, + DateTimeOffset.UtcNow.Day, DateTimeOffset.UtcNow.Hour, 0, 0, TimeSpan.Zero) + + TimeSpan.FromHours(1); + var unixTime = nextRun.UtcTicks / 10000000-62135596800; // UTC ticks since year 0 to Unix Timestamp + + fields.Add(new EmbedFieldBuilder() + .WithName("Note") + .WithValue($"The Auto Member System will run . Membership eligibility is subject to change at the time of evaluation.")); + + var builder = new EmbedBuilder() + // Set up all the basic stuff first + .WithCurrentTimestamp() + .WithColor(0x0c94e0) + .WithFooter(new EmbedFooterBuilder() + .WithText("Instar Auto Member System") + .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) + .WithTitle("Membership Eligibility") + .WithDescription(BuildEligibilityText(eligibility)) + .WithFields(fields); + + return builder.Build(); + } + private async Task BuildMissingItemsText(MembershipEligibility eligibility, IGuildUser user) { var config = await dynamicConfig.GetConfig(); @@ -113,30 +229,30 @@ private async Task BuildMissingItemsText(MembershipEligibility eligibili return missingItemsBuilder.ToString(); } - private static string BuildEligibilityText(MembershipEligibility eligibility) - { - var eligibilityBuilder = new StringBuilder(); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.MissingRoles) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Roles**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.MissingIntroduction) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Introduction**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.TooYoung) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Join Age**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.PunishmentReceived) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Mod Actions**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.NotEnoughMessages) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Messages** (last 24 hours)"); - - return eligibilityBuilder.ToString(); - } + private static string BuildEligibilityText(MembershipEligibility eligibility) + { + var eligibilityBuilder = new StringBuilder(); + eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.MissingRoles) + ? ":x:" + : ":white_check_mark:"); + eligibilityBuilder.AppendLine(" **Roles**"); + eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.MissingIntroduction) + ? ":x:" + : ":white_check_mark:"); + eligibilityBuilder.AppendLine(" **Introduction**"); + eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.TooYoung) + ? ":x:" + : ":white_check_mark:"); + eligibilityBuilder.AppendLine(" **Join Age**"); + eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.PunishmentReceived) + ? ":x:" + : ":white_check_mark:"); + eligibilityBuilder.AppendLine(" **Mod Actions**"); + eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.NotEnoughMessages) + ? ":x:" + : ":white_check_mark:"); + eligibilityBuilder.AppendLine(" **Messages** (last 24 hours)"); + + return eligibilityBuilder.ToString(); + } } \ No newline at end of file diff --git a/InstarBot/Commands/SetBirthdayCommand.cs b/InstarBot/Commands/SetBirthdayCommand.cs index 074ec21..677e619 100644 --- a/InstarBot/Commands/SetBirthdayCommand.cs +++ b/InstarBot/Commands/SetBirthdayCommand.cs @@ -14,9 +14,7 @@ namespace PaxAndromeda.Instar.Commands; public class SetBirthdayCommand(IInstarDDBService ddbService, IMetricService metricService) : BaseCommand { [UsedImplicitly] - [RequireOwner] - [DefaultMemberPermissions(GuildPermission.Administrator)] - [SlashCommand("setbirthday", "Sets your birthday on the server.")] + [SlashCommand("setbirthday", "Sets your birthday on the server.")] public async Task SetBirthday( [MinValue(1)] [MaxValue(12)] [Summary(description: "The month you were born.")] Month month, @@ -30,6 +28,15 @@ public async Task SetBirthday( [Autocomplete] int tzOffset = 0) { + if (Context.User is null) + { + Log.Warning("Context.User was null"); + await RespondAsync( + "An unknown error has occurred. Instar developers have been notified.", + ephemeral: true); + return; + } + if ((int)month is < 0 or > 12) { await RespondAsync( @@ -71,10 +78,13 @@ await RespondAsync( dtUtc); // TODO: Notify staff? - var ok = await ddbService.UpdateUserBirthday(new Snowflake(Context.User!.Id), dtUtc); - - if (ok) + try { + var dbUser = await ddbService.GetOrCreateUserAsync(Context.User); + dbUser.Data.Birthday = dtUtc; + await dbUser.UpdateAsync(); + + Log.Information("User {UserID} birthday set to {DateTime} (UTC time calculated as {UtcTime})", Context.User!.Id, dtLocal, dtUtc); @@ -82,9 +92,9 @@ await RespondAsync( await RespondAsync($"Your birthday was set to {dtLocal:D}.", ephemeral: true); await metricService.Emit(Metric.BS_BirthdaysSet, 1); } - else + catch (Exception ex) { - Log.Warning("Failed to update {UserID}'s birthday due to a DynamoDB failure", + Log.Error(ex, "Failed to update {UserID}'s birthday due to a DynamoDB failure", Context.User!.Id); await RespondAsync("Your birthday could not be set at this time. Please try again later.", diff --git a/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs b/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs index 2adac4c..2dfbcc7 100644 --- a/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs +++ b/InstarBot/Commands/TriggerAutoMemberSystemCommand.cs @@ -5,7 +5,7 @@ namespace PaxAndromeda.Instar.Commands; -public sealed class TriggerAutoMemberSystemCommand(AutoMemberSystem ams) : BaseCommand +public sealed class TriggerAutoMemberSystemCommand(IAutoMemberSystem ams) : BaseCommand { [UsedImplicitly] [RequireOwner] diff --git a/InstarBot/Config/Instar.conf.schema.json b/InstarBot/Config/Instar.conf.schema.json index f4a9b06..8038ac5 100644 --- a/InstarBot/Config/Instar.conf.schema.json +++ b/InstarBot/Config/Instar.conf.schema.json @@ -44,6 +44,20 @@ "type": "string" } } + }, + "AppConfig": { + "type": "object", + "properties": { + "Application": { + "type": "string" + }, + "Environment": { + "type": "string" + }, + "ConfigurationProfile": { + "type": "string" + } + } } }, "required": [ diff --git a/InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs b/InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs new file mode 100644 index 0000000..92942b6 --- /dev/null +++ b/InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs @@ -0,0 +1,243 @@ +using System.Collections; +using System.Reflection; +using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; + +namespace PaxAndromeda.Instar.DynamoModels; + +public class ArbitraryDynamoDBTypeConverter: IPropertyConverter where T: new() +{ + public DynamoDBEntry ToEntry(object value) + { + /* + * Object to DynamoDB Entry + * + * For this conversion, we will only look for public properties. + * We will apply a 1:1 conversion between property names and DynamoDB properties. + * We will also apply this recursively as needed (with a max definable depth) to + * prevent loop conditions. + * + * A few property attributes we need to be aware of are DynamoDBProperty and its + * derivatives, and DynamoDBIgnore. We can ignore any property with + * a DynamoDBIgnore attribute. + * + * Any attribute that inherits from DynamoDBProperty will be handled using special + * logic: The property name can be substituted by the attribute's `AttributeName` + * property (if it exists), and a special converter may be used as well (if it exists). + * + * We will do no special handling for epoch conversions. If no converter is defined, + * we will either handle it natively if it is a primitive type or otherwise known + * to DynamoDBv2 SDK, otherwise we'll just apply this arbitrary type converter. + */ + + return ToDynamoDBEntry(value); + } + + public object FromEntry(DynamoDBEntry entry) + { + return FromDynamoDBEntry(entry.AsDocument()); + } + + private static Document ToDynamoDBEntry(object obj, int maxDepth = 3, int currentDepth = 0) + { + ArgumentNullException.ThrowIfNull(obj); + + if (currentDepth > maxDepth) + throw new InvalidOperationException("Max recursion depth reached"); + + var doc = new Document(); + + // Loop through all public properties of the object + foreach (var property in obj.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + // Ignore write-only or non-readable properties + if (!property.CanRead) continue; + + // Handle properties marked with [DynamoDBIgnore] + if (Attribute.IsDefined(property, typeof(DynamoDBIgnoreAttribute))) + continue; + + var propertyName = property.Name; + + // Check if a DynamoDBProperty is defined on the property + if (Attribute.IsDefined(property, typeof(DynamoDBPropertyAttribute))) + { + var dynamoDBProperty = property.GetCustomAttribute(); + if (!string.IsNullOrEmpty(dynamoDBProperty?.AttributeName)) + { + propertyName = dynamoDBProperty.AttributeName; + } + } + + var propertyValue = property.GetValue(obj); + if (propertyValue == null) continue; + + // Check for converters + var converterAttr = property.GetCustomAttribute()?.Converter; + if (converterAttr != null && Activator.CreateInstance(converterAttr) is IPropertyConverter converter) + { + doc[propertyName] = converter.ToEntry(propertyValue); + } + else + { + // Perform recursive or native handling + doc[propertyName] = ConvertToDynamoDBValue(propertyValue, maxDepth, currentDepth + 1); + } + } + + return doc; + } + + private static DynamoDBEntry ConvertToDynamoDBValue(object value, int maxDepth, int currentDepth) + { + if (value == null) return new Primitive(); + + // Handle primitive types natively supported by DynamoDB + if (value is string || value is bool || value is int || value is long || value is short || value is double || value is float || value is decimal) + { + return new Primitive + { + Value = value + }; + } + + // Handle DateTime + if (value is DateTime dateTimeVal) + { + return new Primitive + { + Value = dateTimeVal.ToString("o") + }; + } + + // Handle collections (e.g., arrays, lists) + if (value is IEnumerable enumerable) + { + var list = new DynamoDBList(); + foreach (var element in enumerable) + { + list.Add(ConvertToDynamoDBValue(element, maxDepth, currentDepth)); + } + return list; + } + + // Handle objects recursively + if (value.GetType().IsClass) + { + return ToDynamoDBEntry(value, maxDepth, currentDepth); + } + + throw new InvalidOperationException($"Cannot convert type {value.GetType()} to DynamoDBEntry."); + } + + public static T FromDynamoDBEntry(Document document, int maxDepth = 3, int currentDepth = 0) where T : new() + { + if (document == null) + throw new ArgumentNullException(nameof(document)); + + if (currentDepth > maxDepth) + throw new InvalidOperationException("Max recursion depth reached."); + + var obj = new T(); + + foreach (var property in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + // Ignore write-only or non-readable properties + if (!property.CanWrite) continue; + + // Skip properties marked with [DynamoDBIgnore] + if (Attribute.IsDefined(property, typeof(DynamoDBIgnoreAttribute))) + continue; + + var propertyName = property.Name; + + // Look for [DynamoDBProperty] to handle custom mappings + if (Attribute.IsDefined(property, typeof(DynamoDBPropertyAttribute))) + { + var dynamoDBProperty = property.GetCustomAttribute(); + if (!string.IsNullOrWhiteSpace(dynamoDBProperty?.AttributeName)) + propertyName = dynamoDBProperty.AttributeName; + } + + // Check if the document contains the property + if (!document.ContainsKey(propertyName)) continue; + + var entry = document[propertyName]; + var converterAttr = property.GetCustomAttribute()?.Converter; + + if (converterAttr != null && Activator.CreateInstance(converterAttr) is IPropertyConverter converter) + { + // Use the custom converter to deserialize + property.SetValue(obj, converter.FromEntry(entry)); + } + else + { + // Perform recursive or default conversion + var convertedValue = FromDynamoDBValue(property.PropertyType, entry, maxDepth, currentDepth + 1); + property.SetValue(obj, convertedValue); + } + } + + return obj; + } + + private static object? FromDynamoDBValue(Type targetType, DynamoDBEntry entry, int maxDepth, int currentDepth) + { + if (entry is Primitive primitive) + { + // Handle primitive types + if (targetType == typeof(string)) return primitive.AsString(); + if (targetType == typeof(bool)) return primitive.AsBoolean(); + if (targetType == typeof(int)) return primitive.AsInt(); + if (targetType == typeof(long)) return primitive.AsLong(); + if (targetType == typeof(short)) return primitive.AsShort(); + if (targetType == typeof(double)) return primitive.AsDouble(); + if (targetType == typeof(float)) return primitive.AsSingle(); + if (targetType == typeof(decimal)) return Convert.ToDecimal(primitive.Value); + if (targetType == typeof(DateTime)) return DateTime.Parse(primitive.AsString()); + + throw new InvalidOperationException($"Unhandled primitive type conversion: {targetType}"); + } + + if (entry is DynamoDBList list) + { + if (typeof(IEnumerable).IsAssignableFrom(targetType)) + { + var elementType = targetType.IsArray + ? targetType.GetElementType() + : targetType.GetGenericArguments().FirstOrDefault(); + if (elementType == null) + throw new InvalidOperationException($"Cannot determine element type for target type: {targetType}"); + + var enumerableType = typeof(List<>).MakeGenericType(elementType); + var resultList = (IList)Activator.CreateInstance(enumerableType); + foreach (var element in list.Entries) + { + resultList.Add(FromDynamoDBValue(elementType, element, maxDepth, currentDepth)); + } + + return targetType.IsArray ? Activator.CreateInstance(targetType, resultList) : resultList; + } + } + + if (entry is Document document) + { + if (targetType.IsClass) + { + // Recurse for nested objects + var fromEntryMethod = typeof(ArbitraryDynamoDBTypeConverter).GetMethod(nameof(FromDynamoDBEntry), + BindingFlags.Public | BindingFlags.Static) + ?.MakeGenericMethod(targetType); + if (fromEntryMethod == null) + throw new InvalidOperationException( + $"Unable to deserialize nested type: {targetType}"); + + return fromEntryMethod.Invoke(null, [ document, maxDepth, currentDepth ]); + } + } + + // Unsupported or unknown type + throw new InvalidOperationException($"Cannot convert DynamoDB entry to type: {targetType}"); + } + +} \ No newline at end of file diff --git a/InstarBot/DynamoModels/EventList.cs b/InstarBot/DynamoModels/EventList.cs new file mode 100644 index 0000000..57f3a05 --- /dev/null +++ b/InstarBot/DynamoModels/EventList.cs @@ -0,0 +1,72 @@ +using System.Collections; +using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; +using Newtonsoft.Json; + +namespace PaxAndromeda.Instar.DynamoModels; + +/// +/// Represents a chronological sequence of events of type . +/// +/// The type of the event, which must implement . +public class EventList : IEnumerable + where T : ITimedEvent +{ + private readonly SortedSet _backbone; + + /// + /// Creates a new . + /// + public EventList() + { + _backbone = new SortedSet(Comparer.Create((x, y) => x.Date.CompareTo(y.Date))); + } + + /// + /// Creates a new from a set of events. + /// + /// The type of the events, which must implement . + public EventList(IEnumerable events) : this() + { + foreach (var item in events) + Add(item); + } + + public T? Latest() => _backbone.Max; + + /// + /// Inserts an item into the sequence while maintaining chronological order. + /// + /// The item to be inserted into the sequence. + public void Add(T item) + => _backbone.Add(item); + + public IEnumerator GetEnumerator() => _backbone.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +public class EventListPropertyConverter : IPropertyConverter where T: ITimedEvent +{ + public DynamoDBEntry ToEntry(object value) + { + if (value is not IEnumerable enumerable) + throw new InvalidOperationException("Value is not enumerable"); + + return DynamoDBList.Create(enumerable); + } + + public object FromEntry(DynamoDBEntry entry) + { + List? entries = entry.AsListOfDocument(); + if (entries is null) + return new EventList(); + + // Convert `entries` to List here... somehow + + var list = entries.Select(x => JsonConvert.DeserializeObject(x.ToJson())).Where(x => x != null); + + + return new EventList(list!); + } +} \ No newline at end of file diff --git a/InstarBot/DynamoModels/InstarDatabaseEntry.cs b/InstarBot/DynamoModels/InstarDatabaseEntry.cs new file mode 100644 index 0000000..938275f --- /dev/null +++ b/InstarBot/DynamoModels/InstarDatabaseEntry.cs @@ -0,0 +1,26 @@ +using Amazon.DynamoDBv2.DataModel; + +namespace PaxAndromeda.Instar.DynamoModels; + +/// +/// Represents a database entry in the Instar application. +/// Provides functionality to encapsulate data and interact with a DynamoDB database context. +/// +/// The type of the data stored in the database entry. +public sealed class InstarDatabaseEntry(IDynamoDBContext context, T data) +{ + /// + /// Represents a property that encapsulates the core data of a database entry. + /// This property holds the data model for the entry and is used within the context + /// of DynamoDB operations to save or update information in the associated table. + /// + public T Data { get; } = data; + + /// + /// Updates the corresponding database entry for the data encapsulated in the instance. + /// Persists changes to the underlying storage system asynchronously. + /// + /// A that can be used to poll or wait for results, or both + public Task UpdateAsync() + => context.SaveAsync(Data); +} \ No newline at end of file diff --git a/InstarBot/DynamoModels/InstarUserData.cs b/InstarBot/DynamoModels/InstarUserData.cs new file mode 100644 index 0000000..b5a09a6 --- /dev/null +++ b/InstarBot/DynamoModels/InstarUserData.cs @@ -0,0 +1,267 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; +using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; +using Discord; +using JetBrains.Annotations; + +namespace PaxAndromeda.Instar.DynamoModels; + +[DynamoDBTable("TestInstarData")] +[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] +public class InstarUserData +{ + [DynamoDBHashKey("user_id", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake? UserID { get; set; } + + [DynamoDBProperty("birthday")] + public DateTime? Birthday { get; set; } + + [DynamoDBProperty("joined")] + public DateTime? Joined { get; set; } + + [DynamoDBProperty("position", Converter = typeof(InstarEnumPropertyConverter))] + public InstarUserPosition? Position { get; set; } + + [DynamoDBProperty("avatars")] + public List>? Avatars { get; set; } + + [DynamoDBProperty("nicknames")] + public List>? Nicknames { get; set; } + + [DynamoDBProperty("usernames")] + public List>? Usernames { get; set; } + + [DynamoDBProperty("modlog")] + public List ModLogs { get; set; } + + [DynamoDBProperty("reports")] + public List Reports { get; set; } + + [DynamoDBProperty("notes")] + public List Notes { get; set; } + + [DynamoDBProperty("amh")] + public AutoMemberHoldRecord? AutoMemberHoldRecord { get; set; } + + public string Username + { + get => Usernames?.LastOrDefault()?.Data ?? ""; + set + { + var time = DateTime.UtcNow; + if (Usernames is null) + { + Usernames = [new InstarUserDataHistoricalEntry(time, value)]; + return; + } + + // Don't add a new username if the latest one matches the current one + if (Usernames.OrderByDescending(n => n.Date).First().Data == value) + return; + + Usernames.Add(new InstarUserDataHistoricalEntry(time, value)); + } + } + + public static InstarUserData CreateFrom(IGuildUser user) + { + return new InstarUserData + { + UserID = user.Id, + Birthday = null, + Joined = user.JoinedAt?.UtcDateTime, + Position = InstarUserPosition.NewMember, + Avatars = + [ + new InstarUserDataHistoricalEntry(DateTime.UtcNow, user.GetAvatarUrl(ImageFormat.Auto, 1024) ?? "") + ], + Nicknames = + [ + new InstarUserDataHistoricalEntry(DateTime.UtcNow, user.Nickname) + ], + Usernames = [ new InstarUserDataHistoricalEntry(DateTime.UtcNow, user.Username) ] + }; + } +} + +public record AutoMemberHoldRecord +{ + [DynamoDBProperty("date")] + public DateTime Date { get; set; } + + [DynamoDBProperty("mod", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake ModeratorID { get; set; } + + [DynamoDBProperty("reason")] + public string Reason { get; set; } +} + +public interface ITimedEvent +{ + DateTime Date { get; } +} + +[UsedImplicitly] +public record InstarUserDataHistoricalEntry : ITimedEvent +{ + [DynamoDBProperty("date")] + public DateTime Date { get; set; } + + [DynamoDBProperty("data")] + public T? Data { get; set; } + + public InstarUserDataHistoricalEntry() + { + Date = DateTime.UtcNow; + Data = default; + } + + public InstarUserDataHistoricalEntry(DateTime date, T data) + { + Date = date; + Data = data; + } +} + +[UsedImplicitly] +public record InstarUserDataNote +{ + [DynamoDBProperty("content")] + public string Content { get; set; } + + [DynamoDBProperty("date")] + public DateTime Date { get; set; } + + [DynamoDBProperty("mod", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake ModeratorID { get; set; } +} + +[UsedImplicitly] +public record InstarUserDataReports +{ + [DynamoDBProperty("message_content")] + public string MessageContent { get; set; } + + [DynamoDBProperty("reason")] + public string Reason { get; set; } + + [DynamoDBProperty("date")] + public DateTime Date { get; set; } + + [DynamoDBProperty("channel", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake Channel { get; set; } + + [DynamoDBProperty("message", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake Message { get; set; } + + [DynamoDBProperty("by_user", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake Reporter { get; set; } +} + +public record InstarModLogEntry +{ + [DynamoDBProperty("context")] + public string Context { get; set; } + + [DynamoDBProperty("date")] + public DateTime Date { get; set; } + + [DynamoDBProperty("mod", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake Moderator { get; set; } + + [DynamoDBProperty("reason")] + public string Reason { get; set; } + + [DynamoDBProperty("expiry")] + public DateTime? Expiry { get; set; } + + [DynamoDBProperty("type", Converter = typeof(InstarEnumPropertyConverter))] + public InstarModActionType Type { get; set; } +} + +public enum InstarUserPosition +{ + [EnumMember(Value = "OWNER")] + Owner, + [EnumMember(Value = "ADMIN")] + Admin, + [EnumMember(Value = "MODERATOR")] + Moderator, + [EnumMember(Value = "SENIOR_HELPER")] + SeniorHelper, + [EnumMember(Value = "HELPER")] + Helper, + [EnumMember(Value = "COMMUNITY_MANAGER")] + CommunityManager, + [EnumMember(Value = "MEMBER")] + Member, + [EnumMember(Value = "NEW_MEMBER")] + NewMember, + [EnumMember(Value = "UNKNOWN")] + Unknown +} + +public enum InstarModActionType +{ + [EnumMember(Value = "BAN")] + Ban, + [EnumMember(Value = "KICK")] + Kick, + [EnumMember(Value = "MUTE")] + Mute, + [EnumMember(Value = "WARN")] + Warn, + [EnumMember(Value = "VOICE_MUTE")] + VoiceMute, + [EnumMember(Value = "SOFT_BAN")] + Softban, + [EnumMember(Value = "VOICE_BAN")] + Voiceban, + [EnumMember(Value = "TIMEOUT")] + Timeout + +} + +public class InstarEnumPropertyConverter : IPropertyConverter where T : Enum +{ + public DynamoDBEntry ToEntry(object value) + { + var pos = (InstarUserPosition) value; + + var name = pos.GetAttributeOfType(); + return name?.Value ?? "UNKNOWN"; + } + + public object FromEntry(DynamoDBEntry entry) + { + var sEntry = entry.AsString(); + if (sEntry is null || string.IsNullOrWhiteSpace(entry.AsString())) + return InstarUserPosition.Unknown; + + var name = Utilities.ToEnum(sEntry); + + return name; + } +} + +public class InstarSnowflakePropertyConverter : IPropertyConverter +{ + public DynamoDBEntry ToEntry(object value) + { + return value switch + { + Snowflake snowflake => snowflake.ID.ToString(), + _ => value.ToString() + }; + } + + public object FromEntry(DynamoDBEntry entry) + { + var sEntry = entry.AsString(); + if (sEntry is null || string.IsNullOrWhiteSpace(entry.AsString()) || !ulong.TryParse(sEntry, out var id)) + return new Snowflake(0); + + return new Snowflake(id); + } +} \ No newline at end of file diff --git a/InstarBot/InstarBot.csproj b/InstarBot/InstarBot.csproj index 761299a..8b801ab 100644 --- a/InstarBot/InstarBot.csproj +++ b/InstarBot/InstarBot.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable PaxAndromeda.Instar @@ -10,33 +10,35 @@ - + $(DefineConstants) + + + + $(DefineConstants);DEBUG;TRACE + full - - - - + + + + - - - - - - - + + + + + + + - - - - - - + + + diff --git a/InstarBot/MembershipEligibility.cs b/InstarBot/MembershipEligibility.cs index fbfd35f..faf65b3 100644 --- a/InstarBot/MembershipEligibility.cs +++ b/InstarBot/MembershipEligibility.cs @@ -3,12 +3,14 @@ namespace PaxAndromeda.Instar; [Flags] public enum MembershipEligibility { - Eligible = 0x0, - NotEligible = 0x1, - AlreadyMember = 0x2, - TooYoung = 0x4, - MissingRoles = 0x8, - MissingIntroduction = 0x10, - PunishmentReceived = 0x20, - NotEnoughMessages = 0x40 + Invalid = 0x0, + Eligible = 0x1, + NotEligible = 0x2, + AlreadyMember = 0x4, + TooYoung = 0x8, + MissingRoles = 0x10, + MissingIntroduction = 0x20, + PunishmentReceived = 0x40, + NotEnoughMessages = 0x80, + AutoMemberHold = 0x100 } \ No newline at end of file diff --git a/InstarBot/Metrics/Metric.cs b/InstarBot/Metrics/Metric.cs index 3722db8..036a2a4 100644 --- a/InstarBot/Metrics/Metric.cs +++ b/InstarBot/Metrics/Metric.cs @@ -37,8 +37,16 @@ public enum Metric [MetricDimension("Service", "Auto Member System")] [MetricName("Users Granted Membership")] AMS_UsersGrantedMembership, - - [MetricDimension("Service", "Discord")] + + [MetricDimension("Service", "Auto Member System")] + [MetricName("DynamoDB Failures")] + AMS_DynamoFailures, + + [MetricDimension("Service", "Auto Member System")] + [MetricName("AMH Application Failures")] + AMS_AMHFailures, + + [MetricDimension("Service", "Discord")] [MetricName("Messages Sent")] Discord_MessagesSent, diff --git a/InstarBot/Modals/UserUpdatedEventArgs.cs b/InstarBot/Modals/UserUpdatedEventArgs.cs new file mode 100644 index 0000000..0c0a2e1 --- /dev/null +++ b/InstarBot/Modals/UserUpdatedEventArgs.cs @@ -0,0 +1,19 @@ +using Discord; + +namespace PaxAndromeda.Instar.Modals; + +public class UserUpdatedEventArgs(Snowflake id, IGuildUser before, IGuildUser after) +{ + public Snowflake ID { get; } = id; + + public IGuildUser Before { get; } = before; + + public IGuildUser After { get; } = after; + + // TODO: add additional parts to this for the data we care about + + /// + /// A flag indicating whether data we care about (e.g. nicknames, usernames) has changed. + /// + public bool HasUpdated => Before.Username != After.Username || Before.Nickname != After.Nickname; +} \ No newline at end of file diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index 6c3520e..b1bfbd7 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -29,10 +29,9 @@ public static async Task Main(string[] args) var configPath = "Config/Instar.debug.conf.json"; #else var configPath = "Config/Instar.conf.json"; -#endif - if (!string.IsNullOrEmpty(cli.ConfigPath)) configPath = cli.ConfigPath; +#endif Log.Information("Config path is {Path}", configPath); IConfiguration config = new ConfigurationBuilder() @@ -125,7 +124,7 @@ private static ServiceProvider ConfigureServices(IConfiguration config) services.AddTransient(); services.AddTransient(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // Commands & Interactions @@ -133,6 +132,7 @@ private static ServiceProvider ConfigureServices(IConfiguration config) services.AddTransient(); services.AddSingleton(); services.AddTransient(); + services.AddTransient(); return services.BuildServiceProvider(); } diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index 28ce01e..79e717e 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -5,13 +5,15 @@ using Discord.WebSocket; using PaxAndromeda.Instar.Caching; using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Metrics; +using PaxAndromeda.Instar.Modals; using Serilog; using Timer = System.Timers.Timer; namespace PaxAndromeda.Instar.Services; -public sealed class AutoMemberSystem +public sealed class AutoMemberSystem : IAutoMemberSystem { private readonly MemoryCache _ddbCache = new("AutoMemberSystem_DDBCache"); private readonly MemoryCache _messageCache = new("AutoMemberSystem_MessageCache"); @@ -42,6 +44,7 @@ public AutoMemberSystem(IDynamicConfigService dynamicConfig, IDiscordService dis _metricService = metricService; discord.UserJoined += HandleUserJoined; + discord.UserUpdated += HandleUserUpdated; discord.MessageReceived += HandleMessageReceived; discord.MessageDeleted += HandleMessageDeleted; @@ -119,20 +122,79 @@ private async Task HandleUserJoined(IGuildUser user) { var cfg = await _dynamicConfig.GetConfig(); - if (await WasUserGrantedMembershipBefore(user.Id)) + var dbUser = await _ddbService.GetUserAsync(user.Id); + if (dbUser is null) { - Log.Information("User {UserID} has been granted membership before. Granting membership again", user.Id); - await GrantMembership(cfg, user); + // Let's create a new user + await _ddbService.CreateUserAsync(InstarUserData.CreateFrom(user)); } else { - await user.AddRoleAsync(cfg.NewMemberRoleID); + switch (dbUser.Data.Position) + { + case InstarUserPosition.NewMember: + case InstarUserPosition.Unknown: + await user.AddRoleAsync(cfg.NewMemberRoleID); + dbUser.Data.Position = InstarUserPosition.NewMember; + await dbUser.UpdateAsync(); + break; + + default: + // Yes, they were a member + Log.Information("User {UserID} has been granted membership before. Granting membership again", user.Id); + await GrantMembership(cfg, user, dbUser); + break; + } } await _metricService.Emit(Metric.Discord_UsersJoined, 1); } - - private void StartTimer() + + private async Task HandleUserUpdated(UserUpdatedEventArgs arg) + { + if (!arg.HasUpdated) + return; + + var user = await _ddbService.GetUserAsync(arg.ID); + if (user is null) + { + // new user for the database, create from the latest data and return + try + { + await _ddbService.CreateUserAsync(InstarUserData.CreateFrom(arg.After)); + Log.Information("Created new user {Username} (user ID {UserID})", arg.After.Username, arg.ID); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to create user with ID {UserID}, username {Username}", arg.ID, arg.After.Username); + } + + return; + } + + // Update the record + bool changed = false; + + if (arg.Before.Username != arg.After.Username) + { + user.Data.Username = arg.After.Username; + changed = true; + } + + if (arg.Before.Nickname != arg.After.Nickname) + { + user.Data.Nicknames?.Add(new InstarUserDataHistoricalEntry(DateTime.UtcNow, arg.After.Nickname)); + changed = true; + } + + if (changed) + { + Log.Information("Updated metadata for user {Username} (user ID {UserID})", arg.After.Username, arg.ID); + await user.UpdateAsync(); + } + } + + private void StartTimer() { // Since we can start the bot in the middle of an hour, // first we must determine the time until the next top @@ -170,7 +232,7 @@ public async Task RunAsync() await _metricService.Emit(Metric.AMS_Runs, 1); var cfg = await _dynamicConfig.GetConfig(); - // Caution: This is an extremely long-running method! + // Caution: This is an extremely long-running method! Log.Information("Beginning auto member routine"); if (cfg.AutoMemberConfig.EnableGaiusCheck) @@ -195,26 +257,44 @@ public async Task RunAsync() var membershipGrants = 0; - newMembers = await newMembers.ToAsyncEnumerable() - .WhereAwait(async user => await CheckEligibility(user) == MembershipEligibility.Eligible).ToListAsync(); + var eligibleMembers = newMembers.Where(user => CheckEligibility(cfg, user) == MembershipEligibility.Eligible) + .ToDictionary(n => new Snowflake(n.Id), n => n); - Log.Verbose("There are {NumNewMembers} users eligible for membership", newMembers.Count); - - foreach (var user in newMembers) - { - // User has all the qualifications, let's update their role - try - { - await GrantMembership(cfg, user); - membershipGrants++; - - Log.Information("Granted {UserId} membership", user.Id); - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to grant user {UserId} membership", user.Id); - } - } + Log.Verbose("There are {NumNewMembers} users eligible for membership", eligibleMembers.Count); + + // Batch get users to save bandwidth + var userData = ( + await _ddbService.GetBatchUsersAsync( + eligibleMembers.Select(n => n.Key)) + ) + .Select(x => (x.Data.UserID!, x)) + .ToDictionary(); + + // Determine which users are not present in DDB and need to be created + var usersToCreate = eligibleMembers.Where(n => !userData.ContainsKey(n.Key)).Select(n => n.Value); + + // Step 1: Create missing users in DDB + await foreach (var (id, user) in CreateMissingUsers(usersToCreate)) + userData.Add(id, user); + + // Step 2: Grant membership to eligible users + foreach (var (id, dbUser) in userData) + { + try + { + // User has all the qualifications, let's update their role + if (!eligibleMembers.TryGetValue(id, out var user)) + throw new BadStateException("Unexpected state: expected ID is missing from eligibleMembers"); + + await GrantMembership(cfg, user, dbUser); + membershipGrants++; + + Log.Information("Granted {UserId} membership", user.Id); + } catch (Exception ex) + { + Log.Warning(ex, "Failed to grant user {UserId} membership", id); + } + } await _metricService.Emit(Metric.AMS_UsersGrantedMembership, membershipGrants); } @@ -224,29 +304,45 @@ public async Task RunAsync() } } - private async Task WasUserGrantedMembershipBefore(Snowflake snowflake) - { - if (_ddbCache.Contains(snowflake.ID.ToString()) && (bool)_ddbCache[snowflake.ID.ToString()]) - return true; - - var grantedMembership = await _ddbService.GetUserMembership(snowflake.ID); - if (grantedMembership is null) - return false; - - // Cache for 6-hour sliding window. If accessed, time is reset. - _ddbCache.Add(snowflake.ID.ToString(), grantedMembership.Value, new CacheItemPolicy - { - SlidingExpiration = TimeSpan.FromHours(6) - }); - - return grantedMembership.Value; - } - - private async Task GrantMembership(InstarDynamicConfiguration cfg, IGuildUser user) + private async IAsyncEnumerable>> CreateMissingUsers(IEnumerable users) + { + foreach (var user in users) + { + InstarDatabaseEntry? dbUser; + try + { + await _ddbService.CreateUserAsync(InstarUserData.CreateFrom(user)); + + // Now, get the user we just created + dbUser = await _ddbService.GetUserAsync(user.Id); + + if (dbUser is null) + { + // Welp, something's wrong with DynamoDB that isn't throwing an + // exception with CreateUserAsync or GetUserAsync. At this point, + // we expect the user to be present in DynamoDB, so we'll treat + // this as an error. + throw new BadStateException("Expected user to be created and returned from DynamoDB"); + } + } catch (Exception ex) + { + await _metricService.Emit(Metric.AMS_DynamoFailures, 1); + Log.Error(ex, "Failed to get or create user with ID {UserID} in DynamoDB", user.Id); + continue; + } + + yield return new KeyValuePair>(user.Id, dbUser); + } + } + + private async Task GrantMembership(InstarDynamicConfiguration cfg, IGuildUser user, + InstarDatabaseEntry dbUser) { await user.AddRoleAsync(cfg.MemberRoleID); await user.RemoveRoleAsync(cfg.NewMemberRoleID); - await _ddbService.UpdateUserMembership(user.Id, true); + + dbUser.Data.Position = InstarUserPosition.Member; + await dbUser.UpdateAsync(); // Remove the cache entry if (_ddbCache.Contains(user.Id.ToString())) @@ -256,10 +352,25 @@ private async Task GrantMembership(InstarDynamicConfiguration cfg, IGuildUser us _introductionPosters.TryRemove(user.Id, out _); } - public async Task CheckEligibility(IGuildUser user) + /// + /// Determines the eligibility of a user for membership based on specific criteria. + /// + /// The current configuration from AppConfig. + /// The user whose eligibility is being evaluated. + /// An enumeration value of type that indicates the user's membership eligibility status. + /// + /// The criteria for membership is as follows: + /// + /// The user must have the required roles (see ) + /// The user must be on the server for a configurable minimum amount of time + /// The user must have posted an introduction + /// The user must have posted enough messages in a configurable amount of time + /// The user must not have been issued a moderator action + /// The user must not already be a member + /// + /// + public MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IGuildUser user) { - var cfg = await _dynamicConfig.GetConfig(); - // We need recent messages here, so load it into // context if it does not exist, such as when the // bot first starts and has not run AMS yet. @@ -285,13 +396,25 @@ public async Task CheckEligibility(IGuildUser user) if (_punishedUsers.ContainsKey(user.Id)) eligibility |= MembershipEligibility.PunishmentReceived; - if (eligibility != MembershipEligibility.Eligible) - eligibility |= MembershipEligibility.NotEligible; + if (user.RoleIds.Contains(cfg.AutoMemberConfig.HoldRole)) + eligibility |= MembershipEligibility.AutoMemberHold; + + if (eligibility != MembershipEligibility.Eligible) + { + eligibility &= ~MembershipEligibility.Eligible; + eligibility |= MembershipEligibility.NotEligible; + } Log.Verbose("User {User} ({UserID}) membership eligibility: {Eligibility}", user.Username, user.Id, eligibility); return eligibility; } + /// + /// Verifies if a user possesses the required roles for automatic membership based on the provided configuration. + /// + /// The dynamic configuration containing role requirements and settings for automatic membership. + /// The user whose roles are being checked against the configuration. + /// True if the user satisfies the role requirements; otherwise, false. private static bool CheckUserRequiredRoles(InstarDynamicConfiguration cfg, IGuildUser user) { // Auto Member Hold overrides all role permissions diff --git a/InstarBot/Services/CloudwatchMetricService.cs b/InstarBot/Services/CloudwatchMetricService.cs index 165c1dd..6dc9b70 100644 --- a/InstarBot/Services/CloudwatchMetricService.cs +++ b/InstarBot/Services/CloudwatchMetricService.cs @@ -30,7 +30,8 @@ public async Task Emit(Metric metric, double value) var datum = new MetricDatum { MetricName = nameAttr is not null ? nameAttr.Name : Enum.GetName(metric), - Value = value + Value = value, + Dimensions = [] }; var attrs = metric.GetAttributesOfType(); diff --git a/InstarBot/Services/DiscordService.cs b/InstarBot/Services/DiscordService.cs index 8284b81..c2fbfe6 100644 --- a/InstarBot/Services/DiscordService.cs +++ b/InstarBot/Services/DiscordService.cs @@ -1,13 +1,14 @@ -using System.Diagnostics.CodeAnalysis; -using Discord; +using Discord; using Discord.Interactions; using Discord.WebSocket; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Wrappers; using Serilog; using Serilog.Events; +using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Services; @@ -22,17 +23,24 @@ public sealed class DiscordService : IDiscordService private readonly IServiceProvider _provider; private readonly IDynamicConfigService _dynamicConfig; private readonly DiscordSocketClient _socketClient; - private readonly AsyncEvent _userJoinedEvent = new(); - private readonly AsyncEvent _messageReceivedEvent = new(); + private readonly AsyncEvent _userJoinedEvent = new(); + private readonly AsyncEvent _userUpdatedEvent = new(); + private readonly AsyncEvent _messageReceivedEvent = new(); private readonly AsyncEvent _messageDeletedEvent = new(); - public event Func UserJoined - { - add => _userJoinedEvent.Add(value); - remove => _userJoinedEvent.Remove(value); - } + public event Func UserJoined + { + add => _userJoinedEvent.Add(value); + remove => _userJoinedEvent.Remove(value); + } + + public event Func UserUpdated + { + add => _userUpdatedEvent.Add(value); + remove => _userUpdatedEvent.Remove(value); + } - public event Func MessageReceived + public event Func MessageReceived { add => _messageReceivedEvent.Add(value); remove => _messageReceivedEvent.Remove(value); @@ -70,6 +78,7 @@ public DiscordService(IServiceProvider provider, IConfiguration config, IDynamic _socketClient.MessageReceived += async message => await _messageReceivedEvent.Invoke(message); _socketClient.MessageDeleted += async (msgCache, _) => await _messageDeletedEvent.Invoke(msgCache.Id); _socketClient.UserJoined += async user => await _userJoinedEvent.Invoke(user); + _socketClient.GuildMemberUpdated += HandleUserUpdate; _interactionService.Log += HandleDiscordLog; _contextCommands = provider.GetServices().ToDictionary(n => n.Name, n => n); @@ -81,6 +90,16 @@ public DiscordService(IServiceProvider provider, IConfiguration config, IDynamic throw new ConfigurationException("TargetGuild is not set"); } + private async Task HandleUserUpdate(Cacheable before, SocketGuildUser after) + { + // Can't do anything if we don't have a before state. + // In the case of a user join, that is handled in UserJoined event. + if (!before.HasValue) + return; + + await _userUpdatedEvent.Invoke(new UserUpdatedEventArgs(after.Id, before.Value, after)); + } + private async Task HandleMessageCommand(SocketMessageCommand arg) { Log.Information("Message command: {CommandName}", arg.CommandName); diff --git a/InstarBot/Services/IAutoMemberSystem.cs b/InstarBot/Services/IAutoMemberSystem.cs new file mode 100644 index 0000000..5490a28 --- /dev/null +++ b/InstarBot/Services/IAutoMemberSystem.cs @@ -0,0 +1,28 @@ +using Discord; +using PaxAndromeda.Instar.ConfigModels; + +namespace PaxAndromeda.Instar.Services; + +public interface IAutoMemberSystem +{ + Task RunAsync(); + + /// + /// Determines the eligibility of a user for membership based on specific criteria. + /// + /// The current configuration from AppConfig. + /// The user whose eligibility is being evaluated. + /// An enumeration value of type that indicates the user's membership eligibility status. + /// + /// The criteria for membership is as follows: + /// + /// The user must have the required roles (see ) + /// The user must be on the server for a configurable minimum amount of time + /// The user must have posted an introduction + /// The user must have posted enough messages in a configurable amount of time + /// The user must not have been issued a moderator action + /// The user must not already be a member + /// + /// + MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IGuildUser user); +} \ No newline at end of file diff --git a/InstarBot/Services/IDiscordService.cs b/InstarBot/Services/IDiscordService.cs index cc7b740..49d312b 100644 --- a/InstarBot/Services/IDiscordService.cs +++ b/InstarBot/Services/IDiscordService.cs @@ -1,11 +1,13 @@ using Discord; +using PaxAndromeda.Instar.Modals; namespace PaxAndromeda.Instar.Services; public interface IDiscordService { event Func UserJoined; - event Func MessageReceived; + event Func UserUpdated; + event Func MessageReceived; event Func MessageDeleted; Task Start(IServiceProvider provider); diff --git a/InstarBot/Services/IInstarDDBService.cs b/InstarBot/Services/IInstarDDBService.cs index c4c954d..40e61af 100644 --- a/InstarBot/Services/IInstarDDBService.cs +++ b/InstarBot/Services/IInstarDDBService.cs @@ -1,14 +1,44 @@ using System.Diagnostics.CodeAnalysis; +using Discord; +using PaxAndromeda.Instar.DynamoModels; namespace PaxAndromeda.Instar.Services; [SuppressMessage("ReSharper", "UnusedMemberInSuper.Global")] public interface IInstarDDBService { - Task UpdateUserBirthday(Snowflake snowflake, DateTime birthday); - Task UpdateUserJoinDate(Snowflake snowflake, DateTime joinDate); - Task UpdateUserMembership(Snowflake snowflake, bool membershipGranted); - Task GetUserBirthday(Snowflake snowflake); - Task GetUserJoinDate(Snowflake snowflake); - Task GetUserMembership(Snowflake snowflake); + /// + /// Retrieves user data from DynamoDB for a provided . + /// + /// The user ID + /// User data associated with the provided , if any exists + /// If the `position` entry does not represent a valid + Task?> GetUserAsync(Snowflake snowflake); + + + /// + /// Retrieves or creates user data from a provided . + /// + /// An instance of . If a new user must be created, + /// information will be pulled from the parameter. + /// An instance of . + /// + /// When a new user is created with this method, it is *not* created in DynamoDB until + /// is called. + /// + Task> GetOrCreateUserAsync(IGuildUser user); + + /// + /// Retrieves a list of user data from a list of . + /// + /// A list of user ID snowflakes to query. + /// A list of containing for the provided . + Task>> GetBatchUsersAsync(IEnumerable snowflakes); + + /// + /// Creates a new user in DynamoDB. + /// + /// An instance of to save into DynamoDB. + /// Nothing. + Task CreateUserAsync(InstarUserData data); } \ No newline at end of file diff --git a/InstarBot/Services/InstarDDBService.cs b/InstarBot/Services/InstarDDBService.cs index 4dacfdd..3d8bf79 100644 --- a/InstarBot/Services/InstarDDBService.cs +++ b/InstarBot/Services/InstarDDBService.cs @@ -1,8 +1,10 @@ using System.Diagnostics.CodeAnalysis; using Amazon; using Amazon.DynamoDBv2; -using Amazon.DynamoDBv2.DocumentModel; +using Amazon.DynamoDBv2.DataModel; +using Discord; using Microsoft.Extensions.Configuration; +using PaxAndromeda.Instar.DynamoModels; using Serilog; namespace PaxAndromeda.Instar.Services; @@ -10,113 +12,54 @@ namespace PaxAndromeda.Instar.Services; [ExcludeFromCodeCoverage] public sealed class InstarDDBService : IInstarDDBService { - private const string TableName = "InstarUserData"; - private const string PrimaryKey = "UserID"; - private const string DateTimeFormat = "yyyy-MM-ddTHH:mm:ssK"; // ISO-8601 - - private readonly AmazonDynamoDBClient _client; + private readonly DynamoDBContext _ddbContext; public InstarDDBService(IConfiguration config) { var region = config.GetSection("AWS").GetValue("Region"); - _client = new AmazonDynamoDBClient(new AWSIAMCredential(config), RegionEndpoint.GetBySystemName(region)); - } - - public async Task UpdateUserBirthday(Snowflake snowflake, DateTime birthday) - { - return await UpdateUserData(snowflake, - DataType.Birthday, - (DynamoDBEntry)birthday.ToString(DateTimeFormat)); - } - - public async Task UpdateUserJoinDate(Snowflake snowflake, DateTime joinDate) - { - return await UpdateUserData(snowflake, - DataType.JoinDate, - (DynamoDBEntry)joinDate.ToString(DateTimeFormat)); - } - - public async Task UpdateUserMembership(Snowflake snowflake, bool membershipGranted) - { - return await UpdateUserData(snowflake, - DataType.Membership, - (DynamoDBEntry)membershipGranted); - } - - public async Task GetUserBirthday(Snowflake snowflake) - { - var entry = await GetUserData(snowflake, DataType.Birthday); - - if (!DateTimeOffset.TryParse(entry?.AsString(), out var dto)) - return null; + var client = new AmazonDynamoDBClient(new AWSIAMCredential(config), RegionEndpoint.GetBySystemName(region)); + _ddbContext = new DynamoDBContextBuilder() + .WithDynamoDBClient(() => client) + .Build(); - return dto; + _ddbContext.ConverterCache.Add(typeof(InstarUserDataHistoricalEntry), new ArbitraryDynamoDBTypeConverter>()); } - - public async Task GetUserJoinDate(Snowflake snowflake) + + public async Task?> GetUserAsync(Snowflake snowflake) { - var entry = await GetUserData(snowflake, DataType.JoinDate); - - if (!DateTimeOffset.TryParse(entry?.AsString(), out var dto)) + try + { + var result = await _ddbContext.LoadAsync(snowflake.ID.ToString()); + return result is null ? null : new InstarDatabaseEntry(_ddbContext, result); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to get user data for {Snowflake}", snowflake); return null; - - return dto; - } - - public async Task GetUserMembership(Snowflake snowflake) - { - var entry = await GetUserData(snowflake, DataType.Membership); - return entry?.AsBoolean(); + } } - private async Task UpdateUserData(Snowflake snowflake, DataType dataType, T data) - where T : DynamoDBEntry + public async Task> GetOrCreateUserAsync(IGuildUser user) { - var table = new TableBuilder(_client, TableName).Build(); + var data = await _ddbContext.LoadAsync(user.Id.ToString()) ?? InstarUserData.CreateFrom(user); - var updateData = new Document(new Dictionary - { - { PrimaryKey, snowflake.ID.ToString() }, - { dataType.ToString(), data } - }); - - var result = await table.UpdateItemAsync(updateData, new UpdateItemOperationConfig - { - ReturnValues = ReturnValues.AllNewAttributes - }); - - return result[PrimaryKey].AsULong() == snowflake.ID && - result[dataType.ToString()].AsString().Equals(data.AsString()); + return new InstarDatabaseEntry(_ddbContext, data); } - private async Task GetUserData(Snowflake snowflake, DataType dataType) + public async Task>> GetBatchUsersAsync(IEnumerable snowflakes) { - var table = new TableBuilder(_client, TableName).Build(); - var scan = table.Query(new Primitive(snowflake.ID.ToString()), new QueryFilter()); - - var results = await scan.GetRemainingAsync(); - - switch (results.Count) - { - case > 1: - Log.Warning("Found duplicate user {UserID} in database!", snowflake.ID); - break; - case 0: - return null; - } + var batches = _ddbContext.CreateBatchGet(); + foreach (var snowflake in snowflakes) + batches.AddKey(snowflake.ID.ToString()); - if (results.First().TryGetValue(dataType.ToString(), out var entry)) - return entry; + await _ddbContext.ExecuteBatchGetAsync(batches); - Log.Warning("Failed to query data type {DataType} for user ID {UserID}", dataType, snowflake.ID); - return null; + return batches.Results.Select(x => new InstarDatabaseEntry(_ddbContext, x)).ToList(); } - private enum DataType + public async Task CreateUserAsync(InstarUserData data) { - Birthday, - JoinDate, - Membership + await _ddbContext.SaveAsync(data); } } \ No newline at end of file diff --git a/InstarBot/Utilities.cs b/InstarBot/Utilities.cs index 743b446..a1a0eb6 100644 --- a/InstarBot/Utilities.cs +++ b/InstarBot/Utilities.cs @@ -1,9 +1,92 @@ using System.Reflection; +using System.Runtime.Serialization; namespace PaxAndromeda.Instar; +public static class EnumExtensions +{ + private static class EnumCache where T : Enum + { + public static readonly IReadOnlyDictionary Map = + typeof(T) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(f => ( + Field: f, + Value: f.GetCustomAttribute()?.Value ?? f.Name, + EnumValue: (T)f.GetValue(null)! + )) + .ToDictionary( + x => x.Value, + x => x.EnumValue, + StringComparer.OrdinalIgnoreCase); + } + + /// + /// Attempts to parse a string representation of an enum value or its associated + /// value to its corresponding enum value. + /// + /// The type of the enum to parse. + /// The string representation of the enum value or its associated + /// value. + /// When this method returns, contains the enum value if parsing succeeded; + /// otherwise, the default value of the enum. + /// + /// true if the string value was successfully parsed to an enum value; + /// otherwise, false. + /// + public static bool TryParseEnumMember(string value, out T result) where T : Enum + { + // NULLABILITY: result can be null if TryGetValue returns false + return EnumCache.Map.TryGetValue(value, out result!); + } + + /// + /// Attempts to parse a string representation of an enum value or its associated + /// value to its corresponding enum value. Throws an exception + /// if the specified value cannot be parsed. + /// + /// The type of the enum to parse. + /// The string representation of the enum value or its associated + /// value. + /// Returns the corresponding enum value of type . + /// Thrown when the specified value does not match any enum value + /// or associated value in the enum type . + public static T ParseEnumMember(string value) where T : Enum + { + return TryParseEnumMember(value, out T result) + ? result + : throw new ArgumentException($"Value '{value}' not found in enum {typeof(T).Name}"); + } + + /// + /// Retrieves the string representation of an enum value as defined by its associated + /// value, or the enum value's name if no attribute is present. + /// + /// The type of the enum. + /// The enum value to retrieve the string representation for. + /// + /// The string representation of the enum value as defined by the , + /// or the enum value's name if no attribute is present. + /// + public static string GetEnumMemberValue(this T value) where T : Enum + { + return EnumCache.Map + .FirstOrDefault(x => EqualityComparer.Default.Equals(x.Value, value)) + .Key ?? value.ToString(); + } +} + public static class Utilities { + /// + /// Retrieves a list of attributes of a specified type defined on an enum value . + /// + /// The type of the attribute to retrieve. + /// The enum value whose attributes are to be retrieved. + /// + /// A list of attributes of the specified type associated with the enum value; + /// or null if no attributes of the specified type are found. + /// public static List? GetAttributesOfType(this Enum enumVal) where T : Attribute { var type = enumVal.GetType(); @@ -14,7 +97,17 @@ public static class Utilities var attributes = membersInfo[0].GetCustomAttributes(typeof(T), false); return attributes.Length > 0 ? attributes.OfType().ToList() : null; } - + + /// + /// Retrieves the first custom attribute of the specified type applied to the + /// member that corresponds to the given enum value . + /// + /// The type of attribute to retrieve. + /// The enum value whose member's custom attribute is retrieved. + /// + /// The first custom attribute of type if found; + /// otherwise, null. + /// public static T? GetAttributeOfType(this Enum enumVal) where T : Attribute { var type = enumVal.GetType(); @@ -22,33 +115,31 @@ public static class Utilities return membersInfo.Length == 0 ? null : membersInfo[0].GetCustomAttribute(false); } - public static string Remove(this string text, Range range) + /// + /// Converts the string representation of an enum value or its associated + /// value to its corresponding enum value of type . + /// + /// The type of the enum to convert to. + /// The string representation of the enum value or its associated + /// value. + /// The corresponding enum value of type . + /// Thrown when the specified string does not match any + /// enum value or associated value in the enum type . + public static T ToEnum(string name) where T : Enum { - return text.Remove(range.Start.Value, range.End.Value - range.Start.Value); + return EnumExtensions.ParseEnumMember(name); } - public static Range GetLineBoundaries(string text, int index) + /// + /// Converts a string in SCREAMING_SNAKE_CASE format to PascalCase format. + /// + /// The input string in SCREAMING_SNAKE_CASE format. + /// + /// A string converted from SCREAMING_SNAKE_CASE to PascalCase format. + /// + public static string ScreamingToPascalCase(string input) { - var lineStart = 0; - var lineEnd = 0; - // Find the start of the line and the end of the line - for (var i = index; i >= 0; i--) - if (text[i] == '\r' || text[i] == '\n') - { - lineStart = i+1; - break; - } - for (var i = index; i < text.Length; i++) - if (text[i] == '\r' || text[i] == '\n') - { - lineEnd = i; - break; - } - - if (lineStart > lineEnd) - throw new InvalidOperationException( - "Could not remove total cases from API response: lineStart > lineEnd"); - - return new Range(new Index(lineStart), new Index(lineEnd)); + // COMMUNITY_MANAGER ⇒ CommunityManager + return input.Split('_').Select(piece => piece[0] + piece[1..].ToLower()).Aggregate((a, b) => a + b); } } \ No newline at end of file From 291940c4159382869d5e2b563394991e195d74ea Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Tue, 25 Nov 2025 12:32:07 -0800 Subject: [PATCH 19/53] Added centralized strings management and more integration tests. --- InstarBot.Tests.Common/EmbedVerifier.cs | 149 ++++++ InstarBot.Tests.Common/MetaTests.cs | 42 ++ .../MockInstarDDBServiceTests.cs | 45 ++ .../Models/TestGuildUser.cs | 12 +- InstarBot.Tests.Common/Models/TestUser.cs | 82 +++ .../Services/MockInstarDDBService.cs | 120 +++-- InstarBot.Tests.Common/TestUtilities.cs | 143 ++++- ...arBot.Tests.Integration.csproj.DotSettings | 9 +- .../AutoMemberSystemCommandTests.cs | 224 ++++++++ .../CheckEligibilityCommandTests.cs | 331 +++++++++--- .../Interactions/PageCommandTests.cs | 165 +++--- .../Interactions/ReportUserTests.cs | 21 +- InstarBot/Commands/AutoMemberHoldCommand.cs | 23 +- InstarBot/Commands/CheckEligibilityCommand.cs | 209 +------- InstarBot/Commands/PageCommand.cs | 64 +-- InstarBot/Commands/ReportUserCommand.cs | 48 +- .../Embeds/InstarAutoMemberSystemEmbed.cs | 28 + .../Embeds/InstarCheckEligibilityAMHEmbed.cs | 26 + .../Embeds/InstarCheckEligibilityEmbed.cs | 99 ++++ InstarBot/Embeds/InstarEligibilityEmbed.cs | 71 +++ InstarBot/Embeds/InstarEmbed.cs | 10 + InstarBot/Embeds/InstarPageEmbed.cs | 44 ++ InstarBot/Embeds/InstarReportUserEmbed.cs | 51 ++ InstarBot/InstarBot.csproj | 15 + InstarBot/InstarBot.csproj.DotSettings | 2 + InstarBot/Metrics/MetricDimensionAttribute.cs | 3 + InstarBot/Metrics/MetricNameAttribute.cs | 3 + InstarBot/Services/AutoMemberSystem.cs | 2 + InstarBot/Strings.Designer.cs | 495 ++++++++++++++++++ InstarBot/Strings.resx | 287 ++++++++++ 30 files changed, 2322 insertions(+), 501 deletions(-) create mode 100644 InstarBot.Tests.Common/EmbedVerifier.cs create mode 100644 InstarBot.Tests.Common/MetaTests.cs create mode 100644 InstarBot.Tests.Common/MockInstarDDBServiceTests.cs create mode 100644 InstarBot.Tests.Common/Models/TestUser.cs create mode 100644 InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs create mode 100644 InstarBot/Embeds/InstarAutoMemberSystemEmbed.cs create mode 100644 InstarBot/Embeds/InstarCheckEligibilityAMHEmbed.cs create mode 100644 InstarBot/Embeds/InstarCheckEligibilityEmbed.cs create mode 100644 InstarBot/Embeds/InstarEligibilityEmbed.cs create mode 100644 InstarBot/Embeds/InstarEmbed.cs create mode 100644 InstarBot/Embeds/InstarPageEmbed.cs create mode 100644 InstarBot/Embeds/InstarReportUserEmbed.cs create mode 100644 InstarBot/InstarBot.csproj.DotSettings create mode 100644 InstarBot/Strings.Designer.cs create mode 100644 InstarBot/Strings.resx diff --git a/InstarBot.Tests.Common/EmbedVerifier.cs b/InstarBot.Tests.Common/EmbedVerifier.cs new file mode 100644 index 0000000..f17ac41 --- /dev/null +++ b/InstarBot.Tests.Common/EmbedVerifier.cs @@ -0,0 +1,149 @@ +using System.Collections.Immutable; +using Discord; +using Serilog; + +namespace InstarBot.Tests; + +[Flags] +public enum EmbedVerifierMatchFlags +{ + None, + PartialTitle, + PartialDescription, + PartialAuthorName, + PartialFooterText +} + +public class EmbedVerifier +{ + public EmbedVerifierMatchFlags MatchFlags { get; set; } = EmbedVerifierMatchFlags.None; + + public string? Title { get; set; } + public string? Description { get; set; } + + public string? AuthorName { get; set; } + public string? FooterText { get; set; } + + private readonly List<(string?, string, bool)> _fields = []; + + public void AddField(string name, string value, bool partial = false) + { + _fields.Add((name, value, partial)); + } + + public void AddFieldValue(string value, bool partial = false) + { + _fields.Add((null, value, partial)); + } + + public bool Verify(Embed embed) + { + if (!VerifyString(Title, embed.Title, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialTitle))) + { + Log.Error("Failed to match title: Expected '{Expected}', got '{Actual}'", Title, embed.Title); + return false; + } + + if (!VerifyString(Description, embed.Description, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialDescription))) + { + Log.Error("Failed to match description: Expected '{Expected}', got '{Actual}'", Description, embed.Description); + return false; + } + + if (!VerifyString(AuthorName, embed.Author?.Name, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialAuthorName))) + { + Log.Error("Failed to match author name: Expected '{Expected}', got '{Actual}'", AuthorName, embed.Author?.Name); + return false; + } + + if (!VerifyString(FooterText, embed.Footer?.Text, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialFooterText))) + { + Log.Error("Failed to match footer text: Expected '{Expected}', got '{Actual}'", FooterText, embed.Footer?.Text); + return false; + } + + + return VerifyFields(embed.Fields); + } + + private bool VerifyFields(ImmutableArray embedFields) + { + foreach (var (name, value, partial) in _fields) + { + if (!embedFields.Any(n => VerifyString(name, n.Name, partial) && VerifyString(value, n.Value, partial))) + { + Log.Error("Failed to match field: Expected Name '{ExpectedName}', Value '{ExpectedValue}'", name, value); + return false; + } + } + + return true; + } + + private static bool VerifyString(string? expected, string? actual, bool partial = false) + { + if (expected is null) + return true; + if (actual is null) + return false; + + + if (expected.Contains("{") && expected.Contains("}")) + return TestUtilities.MatchesFormat(actual, expected, partial); + + return partial ? actual.Contains(expected, StringComparison.Ordinal) : actual.Equals(expected, StringComparison.Ordinal); + } + + public static VerifierBuilder Builder() => VerifierBuilder.Create(); + + public sealed class VerifierBuilder + { + private readonly EmbedVerifier _verifier = new(); + + public static VerifierBuilder Create() => new(); + + public VerifierBuilder WithFlags(EmbedVerifierMatchFlags flags) + { + _verifier.MatchFlags = flags; + return this; + } + + public VerifierBuilder WithTitle(string? title) + { + _verifier.Title = title; + return this; + } + + public VerifierBuilder WithDescription(string? description) + { + _verifier.Description = description; + return this; + } + + public VerifierBuilder WithAuthorName(string? authorName) + { + _verifier.AuthorName = authorName; + return this; + } + + public VerifierBuilder WithFooterText(string? footerText) + { + _verifier.FooterText = footerText; + return this; + } + + public VerifierBuilder WithField(string name, string value, bool partial = false) + { + _verifier.AddField(name, value, partial); + return this; + } + + public VerifierBuilder WithField(string value, bool partial = false) + { + _verifier.AddFieldValue(value, partial); + return this; + } + + public EmbedVerifier Build() => _verifier; + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/MetaTests.cs b/InstarBot.Tests.Common/MetaTests.cs new file mode 100644 index 0000000..6a30743 --- /dev/null +++ b/InstarBot.Tests.Common/MetaTests.cs @@ -0,0 +1,42 @@ +using FluentAssertions; +using Xunit; + +namespace InstarBot.Tests; + +public class MetaTests +{ + [Fact] + public static void MatchesFormat_WithValidText_ShouldReturnTrue() + { + const string text = "You are missing an age role."; + const string format = "You are missing {0} {1} role."; + + bool result = TestUtilities.MatchesFormat(text, format); + + result.Should().BeTrue(); + } + [Fact] + public static void MatchesFormat_WithRegexReservedCharacters_ShouldReturnTrue() + { + const string text = "You are missing an age **role**."; + const string format = "You are missing {0} {1} **role**."; + + bool result = TestUtilities.MatchesFormat(text, format); + + result.Should().BeTrue(); + } + + [Theory] + [InlineData("You are missing age role.")] + [InlineData("You are missing an role.")] + [InlineData("")] + [InlineData("luftputefartøyet mitt er fullt av Ã¥l")] + public static void MatchesFormat_WithBadText_ShouldReturnTrue(string text) + { + const string format = "You are missing {0} {1} role."; + + bool result = TestUtilities.MatchesFormat(text, format); + + result.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/MockInstarDDBServiceTests.cs b/InstarBot.Tests.Common/MockInstarDDBServiceTests.cs new file mode 100644 index 0000000..a93b704 --- /dev/null +++ b/InstarBot.Tests.Common/MockInstarDDBServiceTests.cs @@ -0,0 +1,45 @@ +using FluentAssertions; +using InstarBot.Tests.Models; +using InstarBot.Tests.Services; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.DynamoModels; +using Xunit; + +namespace InstarBot.Tests; + +public class MockInstarDDBServiceTests +{ + [Fact] + public static async Task UpdateMember_ShouldPersist() + { + // Arrange + var mockDDB = new MockInstarDDBService(); + var userId = Snowflake.Generate(); + + var user = new TestGuildUser + { + Id = userId, + Username = "username", + JoinedAt = DateTimeOffset.UtcNow + }; + + await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(user)); + + // Act + var retrievedUserEntry = await mockDDB.GetUserAsync(userId); + retrievedUserEntry.Should().NotBeNull(); + retrievedUserEntry.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + Date = DateTime.UtcNow, + ModeratorID = Snowflake.Generate(), + Reason = "test reason" + }; + await retrievedUserEntry.UpdateAsync(); + + // Assert + var newlyRetrievedUserEntry = await mockDDB.GetUserAsync(userId); + + newlyRetrievedUserEntry.Should().NotBeNull(); + newlyRetrievedUserEntry.Data.AutoMemberHoldRecord.Should().NotBeNull(); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestGuildUser.cs b/InstarBot.Tests.Common/Models/TestGuildUser.cs index 708620b..142177c 100644 --- a/InstarBot.Tests.Common/Models/TestGuildUser.cs +++ b/InstarBot.Tests.Common/Models/TestGuildUser.cs @@ -178,16 +178,16 @@ public IReadOnlyCollection RoleIds /// public bool Changed { get; private set; } - public string GuildBannerHash => throw new NotImplementedException(); + public string GuildBannerHash { get; set; } = null!; - public string GlobalName => throw new NotImplementedException(); + public string GlobalName { get; set; } = null!; - public string AvatarDecorationHash => throw new NotImplementedException(); + public string AvatarDecorationHash { get; set; } = null!; - public ulong? AvatarDecorationSkuId => throw new NotImplementedException(); - public PrimaryGuild? PrimaryGuild { get; } + public ulong? AvatarDecorationSkuId { get; set; } = null!; + public PrimaryGuild? PrimaryGuild { get; set; } = null!; - public TestGuildUser Clone() + public TestGuildUser Clone() { return (TestGuildUser) MemberwiseClone(); } diff --git a/InstarBot.Tests.Common/Models/TestUser.cs b/InstarBot.Tests.Common/Models/TestUser.cs new file mode 100644 index 0000000..c815a9d --- /dev/null +++ b/InstarBot.Tests.Common/Models/TestUser.cs @@ -0,0 +1,82 @@ +using Discord; +using System.Diagnostics.CodeAnalysis; +using PaxAndromeda.Instar; + +namespace InstarBot.Tests.Models; + +[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] +[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] +public class TestUser : IUser +{ + public TestUser(Snowflake snowflake) + { + Id = snowflake; + CreatedAt = snowflake.Time; + Username = "username"; + } + + public TestUser(TestGuildUser guildUser) + { + Id = guildUser.Id; + CreatedAt = guildUser.CreatedAt; + Mention = guildUser.Mention; + Status = guildUser.Status; + ActiveClients = guildUser.ActiveClients; + Activities = guildUser.Activities; + AvatarId = guildUser.AvatarId; + Discriminator = guildUser.Discriminator; + DiscriminatorValue = guildUser.DiscriminatorValue; + IsBot = guildUser.IsBot; + IsWebhook = guildUser.IsWebhook; + Username = guildUser.Username; + PublicFlags = guildUser.PublicFlags; + AvatarDecorationHash = guildUser.AvatarDecorationHash; + AvatarDecorationSkuId = guildUser.AvatarDecorationSkuId; + GlobalName = guildUser.GlobalName; + PrimaryGuild = guildUser.PrimaryGuild; + } + + public ulong Id { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public string Mention { get; set; } + public UserStatus Status { get; set; } + public IReadOnlyCollection ActiveClients { get; set; } + public IReadOnlyCollection Activities { get; set; } + + public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + { + return string.Empty; + } + + public string GetDefaultAvatarUrl() + { + return string.Empty; + } + + public string GetDisplayAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + { + throw new NotImplementedException(); + } + + public Task CreateDMChannelAsync(RequestOptions? options = null) + { + throw new NotImplementedException(); + } + + public string GetAvatarDecorationUrl() + { + return string.Empty; + } + + public string AvatarId { get; set; } + public string Discriminator { get; set; } + public ushort DiscriminatorValue { get; set; } + public bool IsBot { get; set; } + public bool IsWebhook { get; set; } + public string Username { get; set; } + public UserProperties? PublicFlags { get; set; } + public string GlobalName { get; set; } + public string AvatarDecorationHash { get; set; } + public ulong? AvatarDecorationSkuId { get; set; } + public PrimaryGuild? PrimaryGuild { get; set; } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs index cb6aecd..10bb92d 100644 --- a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs +++ b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs @@ -1,6 +1,8 @@ +using System.Linq.Expressions; using Amazon.DynamoDBv2.DataModel; using Discord; using Moq; +using Moq.Language.Flow; using PaxAndromeda.Instar; using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; @@ -9,63 +11,117 @@ namespace InstarBot.Tests.Services; /// /// A mock implementation of the IInstarDDBService interface for unit testing purposes. -/// This class provides an in-memory storage mechanism to simulate DynamoDB operations. +/// This class just uses Moq in the background to provide mockable behavior. /// -public sealed class MockInstarDDBService : IInstarDDBService +/// +/// Implementation warning: MockInstarDDBService differs from the actual implementation of +/// InstarDDBService. All returned items from are references, +/// meaning any data set on them will persist for future calls. This is different from the +/// concrete implementation, in which you would need to call to +/// persist changes. +/// +public class MockInstarDDBService : IInstarDDBService { - private readonly Dictionary _localData; + private readonly Mock _ddbContextMock = new (); + private readonly Mock _internalMock = new (); public MockInstarDDBService() { - _localData = new Dictionary(); - } + _internalMock.Setup(n => n.GetUserAsync(It.IsAny())) + .ReturnsAsync((InstarDatabaseEntry?) null); + } public MockInstarDDBService(IEnumerable preload) + : this() { - _localData = preload.ToDictionary(n => n.UserID!, n => n); + foreach (var data in preload) { + + _internalMock + .Setup(n => n.GetUserAsync(data.UserID!)) + .ReturnsAsync(new InstarDatabaseEntry(_ddbContextMock.Object, data)); + } } public void Register(InstarUserData data) { - _localData.TryAdd(data.UserID!, data); - } + _internalMock + .Setup(n => n.GetUserAsync(data.UserID!)) + .ReturnsAsync(new InstarDatabaseEntry(_ddbContextMock.Object, data)); + } public Task?> GetUserAsync(Snowflake snowflake) { - if (!_localData.TryGetValue(snowflake, out var data)) - throw new InvalidOperationException("User not found."); - - var ddbContextMock = new Mock(); - - return Task.FromResult(new InstarDatabaseEntry(ddbContextMock.Object, data))!; + return _internalMock.Object.GetUserAsync(snowflake); } - public Task> GetOrCreateUserAsync(IGuildUser user) + public async Task> GetOrCreateUserAsync(IGuildUser user) { - if (!_localData.TryGetValue(user.Id, out var data)) - data = InstarUserData.CreateFrom(user); + // We can't directly set up this method for mocking due to the custom logic here. + // To work around this, we'll first call the same method on the internal mock. If + // it returns a value, we return that. + var mockedResult = await _internalMock.Object.GetOrCreateUserAsync(user); - var ddbContextMock = new Mock(); + // .GetOrCreateUserAsync is expected to never return null in production. However, + // with mocks, it CAN return null if the method was not set up. + // + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (mockedResult is not null) + return mockedResult; - return Task.FromResult(new InstarDatabaseEntry(ddbContextMock.Object, data)); - } + var result = await _internalMock.Object.GetUserAsync(user.Id); - public Task>> GetBatchUsersAsync(IEnumerable snowflakes) - { - return Task.FromResult(GetLocalUsers(snowflakes) - .Select(n => new InstarDatabaseEntry(new Mock().Object, n)).ToList()); - } + if (result is not null) + return result; - private IEnumerable GetLocalUsers(IEnumerable snowflakes) + // Gotta set up the mock + await CreateUserAsync(InstarUserData.CreateFrom(user)); + result = await _internalMock.Object.GetUserAsync(user.Id); + + if (result is null) + Assert.Fail("Failed to correctly set up mocks in MockInstarDDBService"); + + return result; + } + + public async Task>> GetBatchUsersAsync(IEnumerable snowflakes) { - foreach (var snowflake in snowflakes) - if (_localData.TryGetValue(snowflake, out var data)) - yield return data; + return await GetLocalUsersAsync(snowflakes).ToListAsync(); } + private async IAsyncEnumerable> GetLocalUsersAsync(IEnumerable snowflakes) + { + foreach (var snowflake in snowflakes) + { + var data = await _internalMock.Object.GetUserAsync(snowflake); + + if (data is not null) + yield return data; + } + } + public Task CreateUserAsync(InstarUserData data) { - _localData.TryAdd(data.UserID!, data); - return Task.CompletedTask; - } + _internalMock + .Setup(n => n.GetUserAsync(data.UserID!)) + .ReturnsAsync(new InstarDatabaseEntry(_ddbContextMock.Object, data)); + + return Task.CompletedTask; + } + + /// + /// Configures a setup for the specified expression on the mocked interface, allowing + /// control over the behavior of the mock for the given member. + /// + /// Use this method to define expectations or return values for specific members of when using mocking frameworks. The returned setup object allows chaining of additional + /// configuration methods, such as specifying return values or verifying calls. + /// The type of the value returned by the member specified in the expression. + /// An expression that identifies the member of to set up. Typically, a lambda + /// expression specifying a method or property to mock. + /// An instance that can be used to further configure the behavior of + /// the mock for the specified member. + public ISetup Setup(Expression> expression) + { + return _internalMock.Setup(expression); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/TestUtilities.cs b/InstarBot.Tests.Common/TestUtilities.cs index ad3799f..9fdcace 100644 --- a/InstarBot.Tests.Common/TestUtilities.cs +++ b/InstarBot.Tests.Common/TestUtilities.cs @@ -1,4 +1,5 @@ using System.Linq.Expressions; +using System.Text.RegularExpressions; using Discord; using Discord.Interactions; using FluentAssertions; @@ -63,26 +64,105 @@ public static IServiceProvider GetServices() sc.AddSingleton(GetDynamicConfiguration()); return sc.BuildServiceProvider(); - } - - /// - /// Verifies that the command responded to the user with the correct . - /// - /// A mockup of the command. - /// The message to check for. - /// A flag indicating whether the message should be ephemeral. - /// The type of command. Must implement . - public static void VerifyMessage(Mock command, string message, bool ephemeral = false) - where T : BaseCommand - { - command.Protected().Verify( - "RespondAsync", Times.Once(), - message, ItExpr.IsAny(), - false, ephemeral, ItExpr.IsAny(), ItExpr.IsAny(), - ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); - } - - public static IDiscordService SetupDiscordService(TestContext context = null!) + } + + /// + /// Verifies that the command responded to the user with the correct . + /// + /// A mockup of the command. + /// The string format to check called messages against. + /// A flag indicating whether the message should be ephemeral. + /// A flag indicating whether partial matches are acceptable. + /// The type of command. Must implement . + public static void VerifyMessage(Mock command, string format, bool ephemeral = false, bool partial = false) + where T : BaseCommand + { + command.Protected().Verify( + "RespondAsync", + Times.Once(), + ItExpr.Is( + n => MatchesFormat(n, format, partial)), // text + ItExpr.IsAny(), // embeds + false, // isTTS + ephemeral, // ephemeral + ItExpr.IsAny(), // allowedMentions + ItExpr.IsAny(), // options + ItExpr.IsAny(), // components + ItExpr.IsAny(), // embed + ItExpr.IsAny(), // pollProperties + ItExpr.IsAny() // messageFlags + ); + } + + /// + /// Verifies that the command responded to the user with an embed that satisfies the specified . + /// + /// The type of command. Must implement . + /// A mockup of the command. + /// An instance to verify against. + /// An optional message format, if present. Defaults to null. + /// An optional flag indicating whether the message is expected to be ephemeral. Defaults to false. + /// An optional flag indicating whether partial matches are acceptable. Defaults to false. + public static void VerifyEmbed(Mock command, EmbedVerifier verifier, string? format = null, bool ephemeral = false, bool partial = false) + where T : BaseCommand + { + var msgRef = format is null + ? ItExpr.IsNull() + : ItExpr.Is(n => MatchesFormat(n, format, partial)); + + command.Protected().Verify( + "RespondAsync", + Times.Once(), + msgRef, // text + ItExpr.IsNull(), // embeds + false, // isTTS + ephemeral, // ephemeral + ItExpr.IsAny(), // allowedMentions + ItExpr.IsNull(), // options + ItExpr.IsNull(), // components + ItExpr.Is(e => verifier.Verify(e)), // embed + ItExpr.IsNull(), // pollProperties + ItExpr.IsAny() // messageFlags + ); + } + + public static void VerifyChannelMessage(Mock channel, string format, bool ephemeral = false, bool partial = false) + where T : class, ITextChannel + { + channel.Verify(c => c.SendMessageAsync( + It.Is(s => MatchesFormat(s, format, partial)), + false, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )); + } + + public static void VerifyChannelEmbed(Mock channel, EmbedVerifier verifier, string format, bool ephemeral = false, bool partial = false) + where T : class, ITextChannel + { + channel.Verify(c => c.SendMessageAsync( + It.Is(n => MatchesFormat(n, format, partial)), + false, + It.Is(e => verifier.Verify(e)), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )); + } + + public static IDiscordService SetupDiscordService(TestContext context = null!) => new MockDiscordService(SetupGuild(context)); public static IGaiusAPIService SetupGaiusAPIService(TestContext context = null!) @@ -250,4 +330,27 @@ public static void SetupLogging() .CreateLogger(); Log.Warning("Logging is enabled for this unit test."); } + + /// + /// Returns true if matches the format specified in . + /// + /// The text to validate. + /// The format to check the text against. + /// Allows for partial matching. + /// True if the matches the format in . + public static bool MatchesFormat(string text, string format, bool partial = false) + { + string formatRegex = Regex.Escape(format); + + if (!partial) + formatRegex = $"^{formatRegex}$"; + + // We cannot simply replace the escaped template variables, as that would escape the braces. + formatRegex = formatRegex.Replace("\\{", "{").Replace("\\}", "}"); + + // Replaces any template variable (e.g., {0}, {name}, etc.) with a regex wildcard that matches any text. + formatRegex = Regex.Replace(formatRegex, "{.+?}", "(?:.+?)"); + + return Regex.IsMatch(text, formatRegex); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj.DotSettings b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj.DotSettings index cac3159..7121aa7 100644 --- a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj.DotSettings +++ b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj.DotSettings @@ -1,6 +1,3 @@ - - True \ No newline at end of file + + No + True \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs new file mode 100644 index 0000000..30259be --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs @@ -0,0 +1,224 @@ +using Discord; +using FluentAssertions; +using InstarBot.Tests.Models; +using InstarBot.Tests.Services; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Metrics; +using Xunit; + +namespace InstarBot.Tests.Integration.Interactions; + +public static class AutoMemberSystemCommandTests +{ + private const ulong NewMemberRole = 796052052433698817ul; + private const ulong MemberRole = 793611808372031499ul; + + private static async Task Setup(bool setupAMH = false) + { + TestUtilities.SetupLogging(); + + // This is going to be annoying + var userID = Snowflake.Generate(); + var modID = Snowflake.Generate(); + + var mockDDB = new MockInstarDDBService(); + var mockMetrics = new MockMetricService(); + + var user = new TestGuildUser + { + Id = userID, + Username = "username", + JoinedAt = DateTimeOffset.UtcNow, + RoleIds = [ NewMemberRole ] + }; + + var mod = new TestGuildUser + { + Id = modID, + Username = "mod_username", + JoinedAt = DateTimeOffset.UtcNow, + RoleIds = [MemberRole] + }; + + await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(user)); + await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(mod)); + + if (setupAMH) + { + var ddbRecord = await mockDDB.GetUserAsync(userID); + ddbRecord.Should().NotBeNull(); + ddbRecord.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + Date = DateTime.UtcNow, + ModeratorID = modID, + Reason = "test reason" + }; + await ddbRecord.UpdateAsync(); + } + + var commandMock = TestUtilities.SetupCommandMock( + () => new AutoMemberHoldCommand(mockDDB, TestUtilities.GetDynamicConfiguration(), mockMetrics), + new TestContext + { + UserID = modID + }); + + return new Context(mockDDB, mockMetrics, user, mod, commandMock); + } + + [Fact] + public static async Task HoldMember_WithValidUserAndReason_ShouldCreateRecord() + { + // Arrange + var ctx = await Setup(); + + // Act + await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Success, ephemeral: true); + + var record = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + record.Should().NotBeNull(); + record.Data.AutoMemberHoldRecord.Should().NotBeNull(); + record.Data.AutoMemberHoldRecord.ModeratorID.ID.Should().Be(ctx.Moderator.Id); + record.Data.AutoMemberHoldRecord.Reason.Should().Be("Test reason"); + } + + [Fact] + public static async Task HoldMember_WithNonGuildUser_ShouldGiveError() + { + // Arrange + var ctx = await Setup(); + + // Act + await ctx.Command.Object.HoldMember(new TestUser(ctx.TargetUser), "Test reason"); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_NotGuildMember, ephemeral: true); + } + + [Fact] + public static async Task HoldMember_AlreadyAMHed_ShouldGiveError() + { + // Arrange + var ctx = await Setup(true); + + // Act + await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_AMHAlreadyExists, ephemeral: true); + } + + [Fact] + public static async Task HoldMember_AlreadyMember_ShouldGiveError() + { + // Arrange + var ctx = await Setup(); + await ctx.TargetUser.AddRoleAsync(MemberRole); + + // Act + await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_AlreadyMember, ephemeral: true); + } + + [Fact] + public static async Task HoldMember_WithDynamoDBError_ShouldRespondWithError() + { + // Arrange + var ctx = await Setup(); + + // Act + ctx.DDBService + .Setup(n => n.GetOrCreateUserAsync(It.IsAny())) + .ThrowsAsync(new BadStateException()); + + await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_Unexpected, ephemeral: true); + + var record = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + record.Should().NotBeNull(); + record.Data.AutoMemberHoldRecord.Should().BeNull(); + ctx.Metrics.GetMetricValues(Metric.AMS_AMHFailures).Sum().Should().BeGreaterThan(0); + } + + [Fact] + public static async Task UnholdMember_WithValidUser_ShouldRemoveAMH() + { + // Arrange + var ctx = await Setup(true); + + // Act + await ctx.Command.Object.UnholdMember(ctx.TargetUser); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Success, ephemeral: true); + + var afterRecord = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + afterRecord.Should().NotBeNull(); + afterRecord.Data.AutoMemberHoldRecord.Should().BeNull(); + } + + [Fact] + public static async Task UnholdMember_WithValidUserNoActiveHold_ShouldReturnError() + { + // Arrange + var ctx = await Setup(); + + // Act + await ctx.Command.Object.UnholdMember(ctx.TargetUser); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Error_NoActiveHold, ephemeral: true); + } + + [Fact] + public static async Task UnholdMember_WithNonGuildUser_ShouldReturnError() + { + // Arrange + var ctx = await Setup(); + + // Act + await ctx.Command.Object.UnholdMember(new TestUser(ctx.TargetUser)); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Error_NotGuildMember, ephemeral: true); + } + + [Fact] + public static async Task UnholdMember_WithDynamoError_ShouldReturnError() + { + // Arrange + var ctx = await Setup(true); + + ctx.DDBService + .Setup(n => n.GetOrCreateUserAsync(It.IsAny())) + .ThrowsAsync(new BadStateException()); + + // Act + await ctx.Command.Object.UnholdMember(ctx.TargetUser); + + // Assert + TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Error_Unexpected, ephemeral: true); + + // Sanity check: if the DDB errors out, the AMH should still be there + var afterRecord = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + afterRecord.Should().NotBeNull(); + afterRecord.Data.AutoMemberHoldRecord.Should().NotBeNull(); + } + + private record Context( + MockInstarDDBService DDBService, + MockMetricService Metrics, + TestGuildUser TargetUser, + TestGuildUser Moderator, + Mock Command); +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs index dde6b68..6fd33dc 100644 --- a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs @@ -1,169 +1,328 @@ using Discord; +using FluentAssertions; +using InstarBot.Tests.Models; using InstarBot.Tests.Services; using Moq; -using Moq.Protected; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Services; using Xunit; namespace InstarBot.Tests.Integration.Interactions; public static class CheckEligibilityCommandTests { + private const ulong MemberRole = 793611808372031499ul; + private const ulong NewMemberRole = 796052052433698817ul; - private static Mock SetupCommandMock(CheckEligibilityCommandTestContext context) + private static async Task> SetupCommandMock(CheckEligibilityCommandTestContext context, Action>? setupMocks = null) { + TestUtilities.SetupLogging(); + var mockAMS = new Mock(); mockAMS.Setup(n => n.CheckEligibility(It.IsAny(), It.IsAny())).Returns(context.Eligibility); + var userId = Snowflake.Generate(); + + var mockDDB = new MockInstarDDBService(); + var user = new TestGuildUser + { + Id = userId, + Username = "username", + JoinedAt = DateTimeOffset.Now, + RoleIds = context.Roles + }; - List userRoles = [ - // from Instar.dynamic.test.debug.conf.json: member and new member role, respectively - context.IsMember ? 793611808372031499ul : 796052052433698817ul - ]; + await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(user)); if (context.IsAMH) { - // from Instar.dynamic.test.debug.conf.json - userRoles.Add(966434762032054282); + var ddbUser = await mockDDB.GetUserAsync(userId); + + ddbUser.Should().NotBeNull(); + ddbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + Date = DateTime.UtcNow, + ModeratorID = Snowflake.Generate(), + Reason = "Testing" + }; + + await ddbUser.UpdateAsync(); } var commandMock = TestUtilities.SetupCommandMock( - () => new CheckEligibilityCommand(TestUtilities.GetDynamicConfiguration(), mockAMS.Object, new MockMetricService()), + () => new CheckEligibilityCommand(TestUtilities.GetDynamicConfiguration(), mockAMS.Object, mockDDB, new MockMetricService()), new TestContext { - UserRoles = userRoles + UserID = userId, + UserRoles = context.Roles.Select(n => new Snowflake(n)).ToList() }); + context.DDB = mockDDB; + context.User = user; + return commandMock; } - private static void VerifyResponse(Mock command, string expectedString) + private static EmbedVerifier.VerifierBuilder CreateVerifier() { - command.Protected().Verify( - "RespondAsync", - Times.Once(), - expectedString, // text - ItExpr.IsNull(), // embeds - false, // isTTS - true, // ephemeral - ItExpr.IsNull(), // allowedMentions - ItExpr.IsNull(), // options - ItExpr.IsNull(), // components - ItExpr.IsAny(), // embed - ItExpr.IsAny(), // pollProperties - ItExpr.IsAny() // messageFlags - ); + return EmbedVerifier.Builder() + .WithTitle(Strings.Command_CheckEligibility_EmbedTitle) + .WithFooterText(Strings.Embed_AMS_Footer); } - private static void VerifyResponseEmbed(Mock command, CheckEligibilityCommandTestContext ctx) + + [Fact] + public static async Task CheckEligibilityCommand_WithExistingMember_ShouldEmitValidMessage() { - // Little more convoluted to verify embed content - command.Protected().Verify( - "RespondAsync", - Times.Once(), - ItExpr.IsNull(), // text - ItExpr.IsNull(), // embeds - false, // isTTS - true, // ephemeral - ItExpr.IsNull(), // allowedMentions - ItExpr.IsNull(), // options - ItExpr.IsNull(), // components - ItExpr.Is(e => e.Description.Contains(ctx.DescriptionPattern) && e.Description.Contains(ctx.DescriptionPattern2) && - e.Fields.Any(n => n.Value.Contains(ctx.MissingItemPattern)) - ), // embed - ItExpr.IsNull(), // pollProperties - ItExpr.IsAny() // messageFlags - ); + // Arrange + var ctx = new CheckEligibilityCommandTestContext(false, [ MemberRole ], MembershipEligibility.Eligible); + var mock = await SetupCommandMock(ctx); + + // Act + await mock.Object.CheckEligibility(); + + // Assert + TestUtilities.VerifyMessage(mock, Strings.Command_CheckEligibility_Error_AlreadyMember, true); } [Fact] - public static async Task CheckEligibilityCommand_WithExistingMember_ShouldEmitValidMessage() + public static async Task CheckEligibilityCommand_NoMemberRoles_ShouldEmitValidErrorMessage() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, true, MembershipEligibility.Eligible); - var mock = SetupCommandMock(ctx); + var ctx = new CheckEligibilityCommandTestContext(false, [], MembershipEligibility.Eligible); + var mock = await SetupCommandMock(ctx); // Act await mock.Object.CheckEligibility(); // Assert - VerifyResponse(mock, "You are already a member!"); + TestUtilities.VerifyMessage(mock, Strings.Command_CheckEligibility_Error_NoMemberRoles, true); + } + + [Fact] + public static async Task CheckEligibilityCommand_Eligible_ShouldEmitValidMessage() + { + // Arrange + var verifier = CreateVerifier() + .WithFlags(EmbedVerifierMatchFlags.PartialDescription) + .WithDescription(Strings.Command_CheckEligibility_MessagesEligibility) + .Build(); + + var ctx = new CheckEligibilityCommandTestContext( + false, + [ NewMemberRole ], + MembershipEligibility.Eligible); + + + var mock = await SetupCommandMock(ctx); + + // Act + await mock.Object.CheckEligibility(); + + // Assert + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); } [Fact] public static async Task CheckEligibilityCommand_WithNewMemberAMH_ShouldEmitValidMessage() { // Arrange + var verifier = CreateVerifier() + .WithFlags(EmbedVerifierMatchFlags.PartialDescription) + .WithDescription(Strings.Command_CheckEligibility_AMH_MembershipWithheld) + .WithField(Strings.Command_CheckEligibility_AMH_Why) + .WithField(Strings.Command_CheckEligibility_AMH_WhatToDo) + .WithField(Strings.Command_CheckEligibility_AMH_ContactStaff) + .Build(); + var ctx = new CheckEligibilityCommandTestContext( true, - false, - MembershipEligibility.Eligible, - "Your membership is currently on hold", - MissingItemPattern: "The staff will override an administrative hold"); + [ NewMemberRole ], + MembershipEligibility.Eligible); + - var mock = SetupCommandMock(ctx); + var mock = await SetupCommandMock(ctx); // Act await mock.Object.CheckEligibility(); // Assert - VerifyResponseEmbed(mock, ctx); + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + } + + [Fact] + public static async Task CheckEligibilityCommand_DDBError_ShouldEmitValidMessage() + { + // Arrange + var verifier = CreateVerifier() + .WithFlags(EmbedVerifierMatchFlags.PartialDescription) + .WithDescription(Strings.Command_CheckEligibility_MessagesEligibility) + .Build(); + + var ctx = new CheckEligibilityCommandTestContext( + true, + [ NewMemberRole ], + MembershipEligibility.Eligible); + + var mock = await SetupCommandMock(ctx); + + ctx.DDB.Setup(n => n.GetUserAsync(It.IsAny())).Throws(); + + // Act + await mock.Object.CheckEligibility(); + + // Assert + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); } [Theory] - [InlineData(MembershipEligibility.MissingRoles, "You are missing an age role.")] - [InlineData(MembershipEligibility.MissingIntroduction, "You have not posted an introduction in")] - [InlineData(MembershipEligibility.TooYoung, "You have not been on the server for")] - [InlineData(MembershipEligibility.PunishmentReceived, "You have received a warning or moderator action.")] - [InlineData(MembershipEligibility.NotEnoughMessages, "messages in the past")] - public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMessage(MembershipEligibility eligibility, string pattern) + [InlineData(MembershipEligibility.MissingRoles)] + [InlineData(MembershipEligibility.MissingIntroduction)] + [InlineData(MembershipEligibility.TooYoung)] + [InlineData(MembershipEligibility.PunishmentReceived)] + [InlineData(MembershipEligibility.NotEnoughMessages)] + public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMessage(MembershipEligibility eligibility) { // Arrange - string sectionHeader = eligibility switch + var eligibilityMap = new Dictionary { - MembershipEligibility.MissingRoles => "Roles", - MembershipEligibility.MissingIntroduction => "Introduction", - MembershipEligibility.TooYoung => "Join Age", - MembershipEligibility.PunishmentReceived => "Mod Actions", - MembershipEligibility.NotEnoughMessages => "Messages", - _ => "" + { MembershipEligibility.MissingRoles, Strings.Command_CheckEligibility_MessagesEligibility }, + { MembershipEligibility.MissingIntroduction, Strings.Command_CheckEligibility_IntroductionEligibility }, + { MembershipEligibility.TooYoung, Strings.Command_CheckEligibility_JoinAgeEligibility }, + { MembershipEligibility.PunishmentReceived, Strings.Command_CheckEligibility_ModActionsEligibility }, + { MembershipEligibility.NotEnoughMessages, Strings.Command_CheckEligibility_MessagesEligibility }, }; - // Cheeky way to get another section header - string anotherSectionHeader = (MembershipEligibility) ((int)eligibility << 1) switch + var fieldMap = new Dictionary { - MembershipEligibility.MissingRoles => "Roles", - MembershipEligibility.MissingIntroduction => "Introduction", - MembershipEligibility.TooYoung => "Join Age", - MembershipEligibility.PunishmentReceived => "Mod Actions", - MembershipEligibility.NotEnoughMessages => "Messages", - _ => "Roles" + { MembershipEligibility.MissingRoles, Strings.Command_CheckEligibility_MissingItem_Role }, + { MembershipEligibility.MissingIntroduction, Strings.Command_CheckEligibility_MissingItem_Introduction }, + { MembershipEligibility.TooYoung, Strings.Command_CheckEligibility_MissingItem_TooYoung }, + { MembershipEligibility.PunishmentReceived, Strings.Command_CheckEligibility_MissingItem_PunishmentReceived }, + { MembershipEligibility.NotEnoughMessages, Strings.Command_CheckEligibility_MissingItem_Messages }, }; + var testDescription = eligibilityMap.GetValueOrDefault(eligibility, string.Empty); + //var nontestFieldFormat = eligibilityMap.GetValueOrDefault(eligibility, eligibilityMap[MembershipEligibility.MissingRoles]); + var testFieldMap = fieldMap.GetValueOrDefault(eligibility, string.Empty); + + var verifier = CreateVerifier() + .WithFlags(EmbedVerifierMatchFlags.PartialDescription) + .WithDescription(testDescription) + .WithField(testFieldMap, true) + .Build(); + var ctx = new CheckEligibilityCommandTestContext( false, - false, - MembershipEligibility.NotEligible | eligibility, - $":x: **{sectionHeader}**", - $":white_check_mark: **{anotherSectionHeader}", - pattern); + [ NewMemberRole ], + MembershipEligibility.NotEligible | eligibility); - var mock = SetupCommandMock(ctx); + var mock = await SetupCommandMock(ctx); // Act await mock.Object.CheckEligibility(); // Assert - VerifyResponseEmbed(mock, ctx); + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + } + + [Fact] + public static async Task CheckOtherEligibility_WithEligibleMember_ShouldEmitValidEmbed() + { + // Arrange + var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.Eligible); + var mock = await SetupCommandMock(ctx); + + ctx.User.Should().NotBeNull(); + var verifier = CreateVerifier() + .WithDescription(Strings.Command_Eligibility_EligibleText) + .WithAuthorName(ctx.User.Username) + .Build(); + + // Act + await mock.Object.CheckOtherEligibility(ctx.User); + + // Assert + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + } + + [Fact] + public static async Task CheckOtherEligibility_WithIneligibleMember_ShouldEmitValidEmbed() + { + // Arrange + var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.NotEligible | MembershipEligibility.MissingRoles); + var mock = await SetupCommandMock(ctx); + + ctx.User.Should().NotBeNull(); + var verifier = CreateVerifier() + .WithDescription(Strings.Command_Eligibility_IneligibleText) + .WithAuthorName(ctx.User.Username) + .WithField(Strings.Command_Eligibility_Section_Requirements, Strings.Command_CheckEligibility_RolesEligibility, true) + .Build(); + + // Act + await mock.Object.CheckOtherEligibility(ctx.User); + + // Assert + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + } + + [Fact] + public static async Task CheckOtherEligibility_WithAMHedMember_ShouldEmitValidEmbed() + { + // Arrange + var ctx = new CheckEligibilityCommandTestContext(true, [ NewMemberRole ], MembershipEligibility.NotEligible | MembershipEligibility.MissingRoles); + var mock = await SetupCommandMock(ctx); + + ctx.User.Should().NotBeNull(); + var verifier = CreateVerifier() + .WithDescription(Strings.Command_Eligibility_IneligibleText) + .WithAuthorName(ctx.User.Username) + .WithField(Strings.Command_Eligibility_Section_Hold, Strings.Command_Eligibility_HoldFormat) + .Build(); + + // Act + await mock.Object.CheckOtherEligibility(ctx.User); + + // Assert + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + } + + [Fact] + public static async Task CheckOtherEligibility_WithDynamoError_ShouldEmitValidEmbed() + { + // Arrange + var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.NotEligible | MembershipEligibility.MissingRoles); + var mock = await SetupCommandMock(ctx); + + ctx.DDB.Setup(n => n.GetUserAsync(It.IsAny())) + .Throws(new BadStateException("Bad state")); + + + ctx.User.Should().NotBeNull(); + var verifier = CreateVerifier() + .WithDescription(Strings.Command_Eligibility_IneligibleText) + .WithAuthorName(ctx.User.Username) + .WithField(Strings.Command_Eligibility_Section_Requirements, Strings.Command_CheckEligibility_RolesEligibility, true) + .WithField(Strings.Command_Eligibility_Section_AmbiguousHold, Strings.Command_Eligibility_Error_AmbiguousHold) + .Build(); + + // Act + await mock.Object.CheckOtherEligibility(ctx.User); + + // Assert + TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); } private record CheckEligibilityCommandTestContext( bool IsAMH, - bool IsMember, - MembershipEligibility Eligibility, - string DescriptionPattern = "", - string DescriptionPattern2 = "", - string MissingItemPattern = ""); + List Roles, + MembershipEligibility Eligibility) + { + internal IGuildUser? User { get; set; } + public MockInstarDDBService DDB { get ; set ; } + } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index dbdb3b4..1bb380c 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -1,4 +1,5 @@ using Discord; +using InstarBot.Tests.Models; using InstarBot.Tests.Services; using Moq; using Moq.Protected; @@ -10,6 +11,8 @@ namespace InstarBot.Tests.Integration.Interactions; public static class PageCommandTests { + private const string TestReason = "Test reason for paging"; + private static async Task> SetupCommandMock(PageCommandTestContext context) { // Treat the Test page target as a regular non-staff user on the server @@ -41,40 +44,54 @@ private static async Task GetTeamLead(PageTarget pageTarget) private static async Task VerifyPageEmbedEmitted(Mock command, PageCommandTestContext context) { - var pageTarget = context.PageTarget; - - string expectedString; - - if (context.PagingTeamLeader) - expectedString = $"<@{await GetTeamLead(pageTarget)}>"; - else - switch (pageTarget) - { - case PageTarget.All: - expectedString = string.Join(' ', - await TestUtilities.GetTeams(PageTarget.All).Select(n => Snowflake.GetMention(() => n.ID)) - .ToArrayAsync()); - break; - case PageTarget.Test: - expectedString = "This is a __**TEST**__ page."; - break; - default: - var team = await TestUtilities.GetTeams(pageTarget).FirstAsync(); - expectedString = Snowflake.GetMention(() => team.ID); - break; - } - - command.Protected().Verify( - "RespondAsync", Times.Once(), - expectedString, ItExpr.IsNull(), - false, false, AllowedMentions.All, ItExpr.IsNull(), - ItExpr.IsNull(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); + var verifier = EmbedVerifier.Builder() + .WithFooterText(Strings.Embed_Page_Footer) + .WithDescription("```{0}```"); + + if (context.TargetUser is not null) + verifier = verifier.WithField( + "User", + $"<@{context.TargetUser.Id}>"); + + if (context.TargetChannel is not null) + verifier = verifier.WithField( + "Channel", + $"<#{context.TargetChannel.Id}>"); + + if (context.Message is not null) + verifier = verifier.WithField( + "Message", + $"{context.Message}"); + + string messageFormat; + if (context.PagingTeamLeader) + messageFormat = "<@{0}>"; + else + switch (context.PageTarget) + { + case PageTarget.All: + messageFormat = string.Join(' ', + await TestUtilities.GetTeams(PageTarget.All).Select(n => Snowflake.GetMention(() => n.ID)) + .ToArrayAsync()); + break; + case PageTarget.Test: + messageFormat = Strings.Command_Page_TestPageMessage; + break; + default: + var team = await TestUtilities.GetTeams(context.PageTarget).FirstAsync(); + messageFormat = Snowflake.GetMention(() => team.ID); + break; + } + + + TestUtilities.VerifyEmbed(command, verifier.Build(), messageFormat); } [Fact(DisplayName = "User should be able to page when authorized")] public static async Task PageCommand_Authorized_WhenPagingTeam_ShouldPageCorrectly() { // Arrange + TestUtilities.SetupLogging(); var context = new PageCommandTestContext( PageTarget.Owner, PageTarget.Moderator, @@ -84,32 +101,54 @@ public static async Task PageCommand_Authorized_WhenPagingTeam_ShouldPageCorrect var command = await SetupCommandMock(context); // Act - await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); - - // Assert - await VerifyPageEmbedEmitted(command, context); - } - - [Fact(DisplayName = "User should be able to page a team's teamleader")] - public static async Task PageCommand_Authorized_WhenPagingTeamLeader_ShouldPageCorrectly() - { - // Arrange - var context = new PageCommandTestContext( - PageTarget.Owner, - PageTarget.Moderator, - true - ); - - var command = await SetupCommandMock(context); - - // Act - await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert await VerifyPageEmbedEmitted(command, context); - } - - [Fact(DisplayName = "Any staff member should be able to use the Test page")] + } + + [Fact(DisplayName = "User should be able to page a team's teamleader")] + public static async Task PageCommand_Authorized_WhenPagingTeamLeader_ShouldPageCorrectly() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Owner, + PageTarget.Moderator, + true + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); + + // Assert + await VerifyPageEmbedEmitted(command, context); + } + + [Fact(DisplayName = "User should be able to page a with user, channel and message")] + public static async Task PageCommand_Authorized_WhenPagingWithData_ShouldPageCorrectly() + { + // Arrange + var context = new PageCommandTestContext( + PageTarget.Owner, + PageTarget.Moderator, + true, + TargetUser: new TestUser(Snowflake.Generate()), + TargetChannel: new TestChannel(Snowflake.Generate()), + Message: "" + ); + + var command = await SetupCommandMock(context); + + // Act + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, context.Message!, context.TargetUser, context.TargetChannel); + + // Assert + await VerifyPageEmbedEmitted(command, context); + } + + [Fact(DisplayName = "Any staff member should be able to use the Test page")] public static async Task PageCommand_AnyStaffMember_WhenPagingTest_ShouldPageCorrectly() { var targets = Enum.GetValues().Except([PageTarget.All, PageTarget.Test]); @@ -126,7 +165,7 @@ public static async Task PageCommand_AnyStaffMember_WhenPagingTest_ShouldPageCor var command = await SetupCommandMock(context); // Act - await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert @@ -147,7 +186,7 @@ public static async Task PageCommand_Authorized_WhenOwnerPagingAll_ShouldPageCor var command = await SetupCommandMock(context); // Act - await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert await VerifyPageEmbedEmitted(command, context); @@ -166,12 +205,12 @@ public static async Task PageCommand_Authorized_WhenOwnerPagingAllTeamLead_Shoul var command = await SetupCommandMock(context); // Act - await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert TestUtilities.VerifyMessage( command, - "Failed to send page. The 'All' team does not have a teamleader. If intended to page the owner, please select the Owner as the team.", + Strings.Command_Page_Error_NoAllTeamlead, true ); } @@ -189,12 +228,12 @@ public static async Task PageCommand_Unauthorized_WhenAttemptingToPage_ShouldFai var command = await SetupCommandMock(context); // Act - await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert TestUtilities.VerifyMessage( command, - "You are not authorized to use this command.", + Strings.Command_Page_Error_NotAuthorized, true ); } @@ -212,14 +251,20 @@ public static async Task PageCommand_Authorized_WhenHelperAttemptsToPageAll_Shou var command = await SetupCommandMock(context); // Act - await command.Object.Page(context.PageTarget, "This is a test reason", context.PagingTeamLeader, string.Empty); + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert TestUtilities.VerifyMessage( command, - "You are not authorized to send a page to the entire staff team.", + Strings.Command_Page_Error_FullTeamNotAuthorized, true ); } - private record PageCommandTestContext(PageTarget UserTeamID, PageTarget PageTarget, bool PagingTeamLeader); + private record PageCommandTestContext( + PageTarget UserTeamID, + PageTarget PageTarget, + bool PagingTeamLeader, + IUser? TargetUser = null, + IChannel? TargetChannel = null, + string? Message = null); } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs index eed8d1a..c71b1bf 100644 --- a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -23,6 +23,15 @@ public static async Task ReportUser_WhenReportingNormally_ShouldNotifyStaff() var (command, interactionContext, channelMock) = SetupMocks(context); + var verifier = EmbedVerifier.Builder() + .WithFooterText(Strings.Embed_UserReport_Footer) + .WithField("Reason", $"```{context.Reason}```") + .WithField("Message Content", "{0}") + .WithField("User", "<@{0}>") + .WithField("Channel", "<#{0}>") + .WithField("Reported By", "<@{0}>") + .Build(); + // Act await command.Object.HandleCommand(interactionContext.Object); await command.Object.ModalResponse(new ReportMessageModal @@ -31,13 +40,11 @@ await command.Object.ModalResponse(new ReportMessageModal }); // Assert - TestUtilities.VerifyMessage(command, "Your report has been sent.", true); + TestUtilities.VerifyMessage(command, Strings.Command_ReportUser_ReportSent, true); + + + TestUtilities.VerifyChannelEmbed(channelMock, verifier, "{0}"); - channelMock.Verify(n => n.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny())); context.ResultEmbed.Should().NotBeNull(); var embed = context.ResultEmbed; @@ -68,7 +75,7 @@ await command.Object.ModalResponse(new ReportMessageModal }); // Assert - TestUtilities.VerifyMessage(command, "Report expired. Please try again.", true); + TestUtilities.VerifyMessage(command, Strings.Command_ReportUser_ReportExpired, true); } private static (Mock, Mock, Mock) SetupMocks(ReportContext context) diff --git a/InstarBot/Commands/AutoMemberHoldCommand.cs b/InstarBot/Commands/AutoMemberHoldCommand.cs index 5cc94e6..daa858f 100644 --- a/InstarBot/Commands/AutoMemberHoldCommand.cs +++ b/InstarBot/Commands/AutoMemberHoldCommand.cs @@ -33,7 +33,8 @@ string reason { if (user is not IGuildUser guildUser) { - await RespondAsync($"Error while attempting to withhold membership for <@{user.Id}>: User is not a guild member.", ephemeral: true); + + await RespondAsync(string.Format(Strings.Command_AutoMemberHold_Error_NotGuildMember, user.Id), ephemeral: true); return; } @@ -41,12 +42,18 @@ string reason if (guildUser.RoleIds.Contains(config.MemberRoleID)) { - await RespondAsync($"Error while attempting to withhold membership for <@{user.Id}>: User is already a member.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_AutoMemberHold_Error_AlreadyMember, user.Id), ephemeral: true); return; } var dbUser = await ddbService.GetOrCreateUserAsync(guildUser); + if (dbUser.Data.AutoMemberHoldRecord is not null) + { + await RespondAsync(string.Format(Strings.Command_AutoMemberHold_Error_AMHAlreadyExists, user.Id), ephemeral: true); + return; + } + dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord { ModeratorID = modId, @@ -56,7 +63,7 @@ string reason await dbUser.UpdateAsync(); // TODO: configurable duration? - await RespondAsync($"Membership for user <@{user.Id}> has been withheld. Staff will be notified in one week to review.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_AutoMemberHold_Success, user.Id), ephemeral: true); } catch (Exception ex) { await metricService.Emit(Metric.AMS_AMHFailures, 1); @@ -65,7 +72,7 @@ string reason try { // It is entirely possible that RespondAsync threw this error. - await RespondAsync($"Error while attempting to withhold membership for <@{user.Id}>: An unexpected error has occurred while configuring the AMH.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_AutoMemberHold_Error_Unexpected, user.Id), ephemeral: true); } catch { // swallow the exception @@ -85,7 +92,7 @@ IUser user if (user is not IGuildUser guildUser) { - await RespondAsync($"Error while attempting to remove auto member hold for <@{user.Id}>: User is not a guild member.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_AutoMemberUnhold_Error_NotGuildMember, user.Id), ephemeral: true); return; } @@ -94,14 +101,14 @@ IUser user var dbUser = await ddbService.GetOrCreateUserAsync(guildUser); if (dbUser.Data.AutoMemberHoldRecord is null) { - await RespondAsync($"Error while attempting to remove auto member hold for <@{user.Id}>: User does not have an active auto member hold.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_AutoMemberUnhold_Error_NoActiveHold, user.Id), ephemeral: true); return; } dbUser.Data.AutoMemberHoldRecord = null; await dbUser.UpdateAsync(); - await RespondAsync($"Auto member hold for user <@{user.Id}> has been removed.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_AutoMemberUnhold_Success, user.Id), ephemeral: true); } catch (Exception ex) { @@ -110,7 +117,7 @@ IUser user try { - await RespondAsync($"Error while attempting to remove auto member hold for <@{user.Id}>: An unexpected error has occurred while removing the AMH.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_AutoMemberUnhold_Error_Unexpected, user.Id), ephemeral: true); } catch { diff --git a/InstarBot/Commands/CheckEligibilityCommand.cs b/InstarBot/Commands/CheckEligibilityCommand.cs index 2caac84..d429cb3 100644 --- a/InstarBot/Commands/CheckEligibilityCommand.cs +++ b/InstarBot/Commands/CheckEligibilityCommand.cs @@ -1,12 +1,12 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text; using Discord; using Discord.Interactions; using JetBrains.Annotations; -using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Embeds; using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Serilog; +using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Commands; @@ -27,32 +27,44 @@ public async Task CheckEligibility() if (Context.User is null) { Log.Error("Checking eligibility, but Context.User is null"); - await RespondAsync("An internal error has occurred. Please try again later.", ephemeral: true); + await RespondAsync(Strings.Command_CheckEligibility_Error_Internal, ephemeral: true); } if (!Context.User!.RoleIds.Contains(config.MemberRoleID) && !Context.User!.RoleIds.Contains(config.NewMemberRoleID)) { - await RespondAsync("You do not have the New Member or Member roles. Please contact staff to have this corrected.", ephemeral: true); + await RespondAsync(Strings.Command_CheckEligibility_Error_NoMemberRoles, ephemeral: true); return; } if (Context.User!.RoleIds.Contains(config.MemberRoleID)) { - await RespondAsync("You are already a member!", ephemeral: true); + await RespondAsync(Strings.Command_CheckEligibility_Error_AlreadyMember, ephemeral: true); return; } + + bool isDDBAMH = false; + try + { + var ddbUser = await ddbService.GetOrCreateUserAsync(Context.User); + isDDBAMH = ddbUser.Data.AutoMemberHoldRecord is not null; + } catch (Exception ex) + { + await metricService.Emit(Metric.AMS_DynamoFailures, 1); + Log.Error(ex, "Failed to retrieve AMH status for user {UserID} from DynamoDB", Context.User.Id); + } - if (Context.User!.RoleIds.Contains(config.AutoMemberConfig.HoldRole)) + if (Context.User!.RoleIds.Contains(config.AutoMemberConfig.HoldRole) || isDDBAMH) { // User is on hold - await RespondAsync(embed: BuildAMHEmbed(), ephemeral: true); + await RespondAsync(embed: new InstarCheckEligibilityAMHEmbed().Build(), ephemeral: true); return; } - var embed = await BuildEligibilityEmbed(config, Context.User); - Log.Debug("Responding..."); - await RespondAsync(embed: embed, ephemeral: true); + + var eligibility = autoMemberSystem.CheckEligibility(config, Context.User); + + await RespondAsync(embed: new InstarCheckEligibilityEmbed(Context.User, eligibility, config).Build(), ephemeral: true); await metricService.Emit(Metric.AMS_EligibilityCheck, 1); } @@ -70,29 +82,14 @@ public async Task CheckOtherEligibility(IUser user) var eligibility = autoMemberSystem.CheckEligibility(cfg, guildUser); - // Let's build a fancy embed - var fields = new List(); - - bool hasAMH = false; + AutoMemberHoldRecord? amhRecord = null; + bool hasError = false; try { var dbUser = await ddbService.GetOrCreateUserAsync(guildUser); if (dbUser.Data.AutoMemberHoldRecord is not null) { - StringBuilder amhContextBuilder = new(); - amhContextBuilder.AppendLine($"**Mod:** <@{dbUser.Data.AutoMemberHoldRecord.ModeratorID.ID}>"); - amhContextBuilder.AppendLine("**Reason:**"); - amhContextBuilder.AppendLine($"```{dbUser.Data.AutoMemberHoldRecord.Reason}```"); - - amhContextBuilder.Append("**Date:** "); - - var secondsSinceEpoch = (long) Math.Floor((dbUser.Data.AutoMemberHoldRecord.Date - DateTime.UnixEpoch).TotalSeconds); - amhContextBuilder.Append($" ()"); - - fields.Add(new EmbedFieldBuilder() - .WithName(":warning: Auto Member Hold") - .WithValue(amhContextBuilder.ToString())); - hasAMH = true; + amhRecord = dbUser.Data.AutoMemberHoldRecord; } } catch (Exception ex) { @@ -100,159 +97,9 @@ public async Task CheckOtherEligibility(IUser user) // Since we can't give exact details, we'll just note that there was an error // and just confirm that the member's AMH status is unknown. - fields.Add(new EmbedFieldBuilder() - .WithName(":warning: Possible Auto Member Hold") - .WithValue("Instar encountered an error while attempting to retrieve AMH details. If the user is otherwise eligible but is not being granted membership, it is possible that they are AMHed. Please try again later.")); - } - - // Only add eligibility requirements if the user is not AMHed - if (!hasAMH) - { - fields.Add(new EmbedFieldBuilder() - .WithName(":small_blue_diamond: Requirements") - .WithValue(BuildEligibilityText(eligibility))); + hasError = true; } - var builder = new EmbedBuilder() - .WithCurrentTimestamp() - .WithTitle("Membership Eligibility") - .WithFooter(new EmbedFooterBuilder() - .WithText("Instar Auto Member System") - .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) - .WithAuthor(new EmbedAuthorBuilder() - .WithName(user.Username) - .WithIconUrl(user.GetAvatarUrl())) - .WithDescription($"At this time, <@{user.Id}> is " + (!eligibility.HasFlag(MembershipEligibility.Eligible) ? "__not__ " : "") + " eligible for membership.") - .WithFields(fields); - - await RespondAsync(embed: builder.Build(), ephemeral: true); - } - - private static Embed BuildAMHEmbed() - { - var fields = new List - { - new EmbedFieldBuilder() - .WithName("Why?") - .WithValue("Your activity has been flagged as unusual by our system. This can happen for a variety of reasons, including antisocial behavior, repeated rule violations in a short period of time, or other behavior that is deemed disruptive."), - new EmbedFieldBuilder() - .WithName("What can I do?") - .WithValue("The staff will override an administrative hold in time when an evaluation of your behavior has been completed by the staff."), - new EmbedFieldBuilder() - .WithName("Should I contact Staff?") - .WithValue("No, the staff will not accelerate this process by request.") - }; - - var builder = new EmbedBuilder() - // Set up all the basic stuff first - .WithCurrentTimestamp() - .WithFooter(new EmbedFooterBuilder() - .WithText("Instar Auto Member System") - .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) - .WithTitle("Membership Eligibility") - .WithDescription("Your membership is currently on hold due to an administrative override. This means that you will not be granted the Member role automatically.") - .WithFields(fields); - - return builder.Build(); - } - - private async Task BuildEligibilityEmbed(InstarDynamicConfiguration config, IGuildUser user) - { - var eligibility = autoMemberSystem.CheckEligibility(config, user); - - Log.Debug("Building response embed..."); - var fields = new List(); - if (eligibility.HasFlag(MembershipEligibility.NotEligible)) - { - fields.Add(new EmbedFieldBuilder() - .WithName("Missing Items") - .WithValue(await BuildMissingItemsText(eligibility, user))); - } - - var nextRun = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, - DateTimeOffset.UtcNow.Day, DateTimeOffset.UtcNow.Hour, 0, 0, TimeSpan.Zero) - + TimeSpan.FromHours(1); - var unixTime = nextRun.UtcTicks / 10000000-62135596800; // UTC ticks since year 0 to Unix Timestamp - - fields.Add(new EmbedFieldBuilder() - .WithName("Note") - .WithValue($"The Auto Member System will run . Membership eligibility is subject to change at the time of evaluation.")); - - var builder = new EmbedBuilder() - // Set up all the basic stuff first - .WithCurrentTimestamp() - .WithColor(0x0c94e0) - .WithFooter(new EmbedFooterBuilder() - .WithText("Instar Auto Member System") - .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) - .WithTitle("Membership Eligibility") - .WithDescription(BuildEligibilityText(eligibility)) - .WithFields(fields); - - return builder.Build(); - } - - private async Task BuildMissingItemsText(MembershipEligibility eligibility, IGuildUser user) - { - var config = await dynamicConfig.GetConfig(); - - if (eligibility == MembershipEligibility.Eligible) - return string.Empty; - - var missingItemsBuilder = new StringBuilder(); - - if (eligibility.HasFlag(MembershipEligibility.MissingRoles)) - { - // What roles are we missing? - foreach (var roleGroup in config.AutoMemberConfig.RequiredRoles) - { - if (user.RoleIds.Intersect(roleGroup.Roles.Select(n => n.ID)).Any()) continue; - var prefix = "aeiouAEIOU".Contains(roleGroup.GroupName[0]) ? "an" : "a"; // grammar hack :) - missingItemsBuilder.AppendLine( - $"- You are missing {prefix} {roleGroup.GroupName.ToLowerInvariant()} role."); - } - } - - if (eligibility.HasFlag(MembershipEligibility.MissingIntroduction)) - missingItemsBuilder.AppendLine($"- You have not posted an introduction in {Snowflake.GetMention(() => config.AutoMemberConfig.IntroductionChannel)}."); - - if (eligibility.HasFlag(MembershipEligibility.TooYoung)) - missingItemsBuilder.AppendLine( - $"- You have not been on the server for {config.AutoMemberConfig.MinimumJoinAge / 3600} hours yet."); - - if (eligibility.HasFlag(MembershipEligibility.PunishmentReceived)) - missingItemsBuilder.AppendLine("- You have received a warning or moderator action."); - - if (eligibility.HasFlag(MembershipEligibility.NotEnoughMessages)) - missingItemsBuilder.AppendLine($"- You have not posted {config.AutoMemberConfig.MinimumMessages} messages in the past {config.AutoMemberConfig.MinimumMessageTime/3600} hours."); - - return missingItemsBuilder.ToString(); - } - - private static string BuildEligibilityText(MembershipEligibility eligibility) - { - var eligibilityBuilder = new StringBuilder(); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.MissingRoles) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Roles**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.MissingIntroduction) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Introduction**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.TooYoung) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Join Age**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.PunishmentReceived) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Mod Actions**"); - eligibilityBuilder.Append(eligibility.HasFlag(MembershipEligibility.NotEnoughMessages) - ? ":x:" - : ":white_check_mark:"); - eligibilityBuilder.AppendLine(" **Messages** (last 24 hours)"); - - return eligibilityBuilder.ToString(); + await RespondAsync(embed: new InstarEligibilityEmbed(guildUser, eligibility, amhRecord, hasError).Build(), ephemeral: true); } } \ No newline at end of file diff --git a/InstarBot/Commands/PageCommand.cs b/InstarBot/Commands/PageCommand.cs index 453f338..ac885a0 100644 --- a/InstarBot/Commands/PageCommand.cs +++ b/InstarBot/Commands/PageCommand.cs @@ -1,13 +1,14 @@ -using System.Diagnostics.CodeAnalysis; -using Ardalis.GuardClauses; +using Ardalis.GuardClauses; using Discord; using Discord.Interactions; using JetBrains.Annotations; using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.Embeds; using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Preconditions; using PaxAndromeda.Instar.Services; using Serilog; +using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Commands; @@ -51,7 +52,7 @@ public async Task Page( string mention; if (team == PageTarget.Test) - mention = "This is a __**TEST**__ page."; + mention = Strings.Command_Page_TestPageMessage; else if (teamLead) mention = await teamService.GetTeamLeadMention(team); else @@ -60,7 +61,7 @@ public async Task Page( Log.Debug("Emitting page to {ChannelName}", Context.Channel?.Name); await RespondAsync( mention, - embed: BuildEmbed(reason, message, user, channel, userTeam!, Context.User), + embed: new InstarPageEmbed(reason, message, user, channel, userTeam!, Context.User).Build(), allowedMentions: AllowedMentions.All); await metricService.Emit(Metric.Paging_SentPages, 1); @@ -68,7 +69,7 @@ await RespondAsync( catch (Exception ex) { Log.Error(ex, "Failed to send page from {User}", Context.User.Id); - await RespondAsync("Failed to process command due to an internal server error.", ephemeral: true); + await RespondAsync(Strings.Command_Page_Error_Unexpected, ephemeral: true); } } @@ -88,7 +89,7 @@ private static bool CheckPermissions(IGuildUser user, Team? team, PageTarget pag if (team is null) { - response = "You are not authorized to use this command."; + response = Strings.Command_Page_Error_NotAuthorized; Log.Information("{User} was not authorized to send a page", user.Id); return false; } @@ -99,7 +100,7 @@ private static bool CheckPermissions(IGuildUser user, Team? team, PageTarget pag // Check permissions. Only mod+ can send an "all" page if (team.Priority > 3 && pageTarget == PageTarget.All) // i.e. Helper, Community Manager { - response = "You are not authorized to send a page to the entire staff team."; + response = Strings.Command_Page_Error_FullTeamNotAuthorized; Log.Information("{User} was not authorized to send a page to the entire staff team", user.Id); return false; } @@ -107,53 +108,8 @@ private static bool CheckPermissions(IGuildUser user, Team? team, PageTarget pag if (pageTarget != PageTarget.All || !teamLead) return true; - response = - "Failed to send page. The 'All' team does not have a teamleader. If intended to page the owner, please select the Owner as the team."; - return false; - } - - /// - /// Builds the page embed. - /// - /// The reason for the page. - /// A message link. May be null. - /// The user being paged about. May be null. - /// The channel being paged about. May be null. - /// The paging user's team - /// The paging user - /// A standard embed embodying all parameters provided - private static Embed BuildEmbed( - string reason, - string message, - IUser? targetUser, - IChannel? channel, - Team userTeam, - IGuildUser pagingUser) - { - var fields = new List(); - - if (targetUser is not null) - fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("User").WithValue($"<@{targetUser.Id}>")); - - if (channel is not null) - fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("Channel").WithValue($"<#{channel.Id}>")); - - if (!string.IsNullOrEmpty(message)) - fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("Message").WithValue(message)); - - var builder = new EmbedBuilder() - // Set up all the basic stuff first - .WithCurrentTimestamp() - .WithColor(userTeam.Color) - .WithAuthor(pagingUser.Nickname ?? pagingUser.Username, pagingUser.GetAvatarUrl()) - .WithFooter(new EmbedFooterBuilder() - .WithText("Instar Paging System") - .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) - // Description - .WithDescription($"```{reason}```") - .WithFields(fields); + response = Strings.Command_Page_Error_NoAllTeamlead; - var embed = builder.Build(); - return embed; + return false; } } \ No newline at end of file diff --git a/InstarBot/Commands/ReportUserCommand.cs b/InstarBot/Commands/ReportUserCommand.cs index 79fcefa..745f433 100644 --- a/InstarBot/Commands/ReportUserCommand.cs +++ b/InstarBot/Commands/ReportUserCommand.cs @@ -1,7 +1,9 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.Caching; +using Ardalis.GuardClauses; using Discord; using Discord.Interactions; +using PaxAndromeda.Instar.Embeds; using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Services; @@ -55,57 +57,21 @@ public async Task ModalResponse(ReportMessageModal modal) // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract if (message is null) { - await RespondAsync("Report expired. Please try again.", ephemeral: true); + await RespondAsync(Strings.Command_ReportUser_ReportExpired, ephemeral: true); return; } await SendReportMessage(modal, message, Context.Guild); - await RespondAsync("Your report has been sent.", ephemeral: true); + await RespondAsync(Strings.Command_ReportUser_ReportSent, ephemeral: true); } private async Task SendReportMessage(ReportMessageModal modal, IMessage message, IInstarGuild guild) { + Guard.Against.Null(Context.User); + var cfg = await dynamicConfig.GetConfig(); - var fields = new List - { - new EmbedFieldBuilder() - .WithIsInline(false) - .WithName("Message Content") - .WithValue($"```{message.Content}```"), - new EmbedFieldBuilder() - .WithIsInline(false) - .WithName("Reason") - .WithValue($"```{modal.ReportReason}```") - }; - - if (message.Author is not null) - fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("User") - .WithValue($"<@{message.Author.Id}>")); - - if (message.Channel is not null) - fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("Channel") - .WithValue($"<#{message.Channel.Id}>")); - - fields.Add(new EmbedFieldBuilder() - .WithIsInline(true) - .WithName("Message") - .WithValue($"https://discord.com/channels/{guild.Id}/{message.Channel?.Id}/{message.Id}")); - - fields.Add(new EmbedFieldBuilder().WithIsInline(false).WithName("Reported By") - .WithValue($"<@{Context.User!.Id}>")); - - var builder = new EmbedBuilder() - // Set up all the basic stuff first - .WithCurrentTimestamp() - .WithColor(0x0c94e0) - .WithAuthor(message.Author?.Username, message.Author?.GetAvatarUrl()) - .WithFooter(new EmbedFooterBuilder() - .WithText("Instar Message Reporting System") - .WithIconUrl("https://spacegirl.s3.us-east-1.amazonaws.com/instar.png")) - .WithFields(fields); - #if DEBUG const string staffPing = "{{staffping}}"; #else @@ -114,7 +80,7 @@ private async Task SendReportMessage(ReportMessageModal modal, IMessage message, await Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel) - .SendMessageAsync(staffPing, embed: builder.Build()); + .SendMessageAsync(staffPing, embed: new InstarReportUserEmbed(modal, Context.User, message, guild).Build()); await metricService.Emit(Metric.ReportUser_ReportsSent, 1); } diff --git a/InstarBot/Embeds/InstarAutoMemberSystemEmbed.cs b/InstarBot/Embeds/InstarAutoMemberSystemEmbed.cs new file mode 100644 index 0000000..ce232de --- /dev/null +++ b/InstarBot/Embeds/InstarAutoMemberSystemEmbed.cs @@ -0,0 +1,28 @@ +using Discord; + +namespace PaxAndromeda.Instar.Embeds; + +public abstract class InstarAutoMemberSystemEmbed (string title) : InstarEmbed +{ + public string Title { get; } = title; + + // There are a couple of variants of this embed, such as the CheckEligibility embed, + // staff eligibility check embed, and the auto-member hold notification embed. + + public override Embed Build() + { + var builder = new EmbedBuilder() + .WithCurrentTimestamp() + .WithTitle(Title) + .WithFooter(new EmbedFooterBuilder() + .WithText(Strings.Embed_AMS_Footer) + .WithIconUrl(InstarLogoUrl)); + + // Let subclasses build out the rest of the embed. + builder = BuildParts(builder); + + return builder.Build(); + } + + protected abstract EmbedBuilder BuildParts(EmbedBuilder builder); +} \ No newline at end of file diff --git a/InstarBot/Embeds/InstarCheckEligibilityAMHEmbed.cs b/InstarBot/Embeds/InstarCheckEligibilityAMHEmbed.cs new file mode 100644 index 0000000..04c5929 --- /dev/null +++ b/InstarBot/Embeds/InstarCheckEligibilityAMHEmbed.cs @@ -0,0 +1,26 @@ +using Discord; + +namespace PaxAndromeda.Instar.Embeds; + +public class InstarCheckEligibilityAMHEmbed() : InstarAutoMemberSystemEmbed(Strings.Command_CheckEligibility_EmbedTitle) +{ + protected override EmbedBuilder BuildParts(EmbedBuilder builder) + { + var fields = new List + { + new EmbedFieldBuilder() + .WithName("Why?") + .WithValue(Strings.Command_CheckEligibility_AMH_Why), + new EmbedFieldBuilder() + .WithName("What can I do?") + .WithValue(Strings.Command_CheckEligibility_AMH_WhatToDo), + new EmbedFieldBuilder() + .WithName("Should I contact Staff?") + .WithValue(Strings.Command_CheckEligibility_AMH_ContactStaff) + }; + + return builder + .WithDescription(Strings.Command_CheckEligibility_AMH_MembershipWithheld) + .WithFields(fields); + } +} \ No newline at end of file diff --git a/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs b/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs new file mode 100644 index 0000000..e584198 --- /dev/null +++ b/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs @@ -0,0 +1,99 @@ +using Discord; +using System.Text; +using PaxAndromeda.Instar.ConfigModels; + +namespace PaxAndromeda.Instar.Embeds; + +public class InstarCheckEligibilityEmbed(IGuildUser user, MembershipEligibility eligibility, InstarDynamicConfiguration config) + : InstarAutoMemberSystemEmbed(Strings.Command_CheckEligibility_EmbedTitle) +{ + protected override EmbedBuilder BuildParts(EmbedBuilder builder) + { + // We only have to focus on the description, author and fields here + + var fields = new List(); + if (eligibility.HasFlag(MembershipEligibility.NotEligible)) + { + fields.Add(new EmbedFieldBuilder() + .WithName("Missing Items") + .WithValue(BuildMissingItemsText())); + } + + var nextRun = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, + DateTimeOffset.UtcNow.Day, DateTimeOffset.UtcNow.Hour, 0, 0, TimeSpan.Zero) + + TimeSpan.FromHours(1); + var unixTime = nextRun.UtcTicks / 10000000-62135596800; // UTC ticks since year 0 to Unix Timestamp + + fields.Add(new EmbedFieldBuilder() + .WithName("Note") + .WithValue(string.Format(Strings.Command_CheckEligibility_NextRuntimeNote, unixTime))); + + return builder + .WithAuthor(new EmbedAuthorBuilder() + .WithName(user.Username) + .WithIconUrl(user.GetAvatarUrl())) + .WithDescription(BuildEligibilityText()) + .WithFields(fields); + } + + private string BuildEligibilityText() + { + var eligibilityBuilder = new StringBuilder(); + + + eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_RolesEligibility, !eligibility.HasFlag(MembershipEligibility.MissingRoles))); + eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_IntroductionEligibility, !eligibility.HasFlag(MembershipEligibility.MissingIntroduction))); + eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_JoinAgeEligibility, !eligibility.HasFlag(MembershipEligibility.TooYoung))); + eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_ModActionsEligibility, !eligibility.HasFlag(MembershipEligibility.PunishmentReceived))); + eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_MessagesEligibility, !eligibility.HasFlag(MembershipEligibility.NotEnoughMessages))); + + return eligibilityBuilder.ToString(); + } + + private static string BuildEligibilityComponent(string format, bool eligible) + { + return string.Format( + format, + eligible + ? Strings.Command_CheckEligibility_EligibleEmoji + : Strings.Command_CheckEligibility_NotEligibleEmoji + ); + } + + private string BuildMissingItemsText() + { + if (eligibility == MembershipEligibility.Eligible) + return string.Empty; + + + var missingItems = new List(); + + if (eligibility.HasFlag(MembershipEligibility.MissingRoles)) + { + // What roles are we missing? + missingItems.AddRange( + from roleGroup in config.AutoMemberConfig.RequiredRoles + where !user.RoleIds.Intersect(roleGroup.Roles.Select(n => n.ID)).Any() + let article = "aeiouAEIOU".Contains(roleGroup.GroupName[0]) ? "an" : "a" // grammar hack + select string.Format(Strings.Command_CheckEligibility_MissingItem_Role, article, roleGroup.GroupName.ToLowerInvariant())); + } + + if (eligibility.HasFlag(MembershipEligibility.MissingIntroduction)) + missingItems.Add(string.Format(Strings.Command_CheckEligibility_MissingItem_Introduction, Snowflake.GetMention(() => config.AutoMemberConfig.IntroductionChannel))); + + if (eligibility.HasFlag(MembershipEligibility.TooYoung)) + missingItems.Add(string.Format(Strings.Command_CheckEligibility_MissingItem_TooYoung, config.AutoMemberConfig.MinimumJoinAge / 3600)); + + if (eligibility.HasFlag(MembershipEligibility.PunishmentReceived)) + missingItems.Add(Strings.Command_CheckEligibility_MissingItem_PunishmentReceived); + + if (eligibility.HasFlag(MembershipEligibility.NotEnoughMessages)) + missingItems.Add(string.Format(Strings.Command_CheckEligibility_MissingItem_Messages, config.AutoMemberConfig.MinimumMessages, config.AutoMemberConfig.MinimumMessageTime / 3600)); + + var missingItemsBuilder = new StringBuilder(); + foreach (var item in missingItems) + missingItemsBuilder.AppendLine($"- {item}"); + + return missingItemsBuilder.ToString(); + } +} \ No newline at end of file diff --git a/InstarBot/Embeds/InstarEligibilityEmbed.cs b/InstarBot/Embeds/InstarEligibilityEmbed.cs new file mode 100644 index 0000000..f5dce9c --- /dev/null +++ b/InstarBot/Embeds/InstarEligibilityEmbed.cs @@ -0,0 +1,71 @@ +using Discord; +using PaxAndromeda.Instar.DynamoModels; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace PaxAndromeda.Instar.Embeds; + +public class InstarEligibilityEmbed(IGuildUser user, MembershipEligibility eligibility, AutoMemberHoldRecord? amhRecord, bool error = false) + : InstarAutoMemberSystemEmbed(Strings.Command_CheckEligibility_EmbedTitle) +{ + protected override EmbedBuilder BuildParts(EmbedBuilder builder) + { + var fields = new List(); + + bool hasAMH = false; + if (amhRecord is not null) + { + var secondsSinceEpoch = (long) Math.Floor((amhRecord.Date - DateTime.UnixEpoch).TotalSeconds); + fields.Add(new EmbedFieldBuilder() + .WithName(Strings.Command_Eligibility_Section_Hold) + .WithValue(string.Format(Strings.Command_Eligibility_HoldFormat, amhRecord.ModeratorID.ID, amhRecord.Reason, secondsSinceEpoch))); + hasAMH = true; + } + + if (!hasAMH && error) + { + fields.Add(new EmbedFieldBuilder() + .WithName(Strings.Command_Eligibility_Section_AmbiguousHold) + .WithValue(Strings.Command_Eligibility_Error_AmbiguousHold)); + } + + // Only add eligibility requirements if the user is not AMHed + if (!hasAMH) + { + fields.Add(new EmbedFieldBuilder() + .WithName(Strings.Command_Eligibility_Section_Requirements) + .WithValue(BuildEligibilityText(eligibility))); + } + + return builder + .WithAuthor(new EmbedAuthorBuilder() + .WithName(user.Username) + .WithIconUrl(user.GetAvatarUrl()) + ) + .WithDescription(eligibility.HasFlag(MembershipEligibility.Eligible) ? Strings.Command_Eligibility_EligibleText : Strings.Command_Eligibility_IneligibleText) + .WithFields(fields); + } + + + [ExcludeFromCodeCoverage(Justification = "This method's output is actually tested by observing the embed output.")] + private static string BuildEligibilityText(MembershipEligibility eligibility) + { + var eligibilityBuilder = new StringBuilder(); + + eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_RolesEligibility, !eligibility.HasFlag(MembershipEligibility.MissingRoles))); + eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_IntroductionEligibility, !eligibility.HasFlag(MembershipEligibility.MissingIntroduction))); + eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_JoinAgeEligibility, !eligibility.HasFlag(MembershipEligibility.TooYoung))); + eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_ModActionsEligibility, !eligibility.HasFlag(MembershipEligibility.PunishmentReceived))); + eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_MessagesEligibility, !eligibility.HasFlag(MembershipEligibility.NotEnoughMessages))); + + return eligibilityBuilder.ToString(); + } + + [ExcludeFromCodeCoverage(Justification = "This method's output is actually tested by observing the embed output.")] + private static string BuildEligibilitySnippet(string format, bool isEligible) + { + return string.Format(format, isEligible + ? Strings.Command_CheckEligibility_EligibleEmoji + : Strings.Command_CheckEligibility_NotEligibleEmoji); + } +} \ No newline at end of file diff --git a/InstarBot/Embeds/InstarEmbed.cs b/InstarBot/Embeds/InstarEmbed.cs new file mode 100644 index 0000000..a795ffa --- /dev/null +++ b/InstarBot/Embeds/InstarEmbed.cs @@ -0,0 +1,10 @@ +using Discord; + +namespace PaxAndromeda.Instar.Embeds; + +public abstract class InstarEmbed +{ + public const string InstarLogoUrl = "https://spacegirl.s3.us-east-1.amazonaws.com/instar.png"; + + public abstract Embed Build(); +} \ No newline at end of file diff --git a/InstarBot/Embeds/InstarPageEmbed.cs b/InstarBot/Embeds/InstarPageEmbed.cs new file mode 100644 index 0000000..8f408cb --- /dev/null +++ b/InstarBot/Embeds/InstarPageEmbed.cs @@ -0,0 +1,44 @@ +using Discord; +using PaxAndromeda.Instar.ConfigModels; +using System.Threading.Channels; + +namespace PaxAndromeda.Instar.Embeds; + +public class InstarPageEmbed ( + string reason, + string message, + IUser? targetUser, + IChannel? targetChannel, + Team pagingTeam, + IGuildUser pagingUser) + : InstarEmbed +{ + public override Embed Build() + { + var fields = new List(); + + if (targetUser is not null) + fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("User").WithValue($"<@{targetUser.Id}>")); + + if (targetChannel is not null) + fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("Channel").WithValue($"<#{targetChannel.Id}>")); + + if (!string.IsNullOrEmpty(message)) + fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("Message").WithValue(message)); + + var builder = new EmbedBuilder() + // Set up all the basic stuff first + .WithCurrentTimestamp() + .WithColor(pagingTeam.Color) + .WithAuthor(pagingUser.Nickname ?? pagingUser.Username, pagingUser.GetAvatarUrl()) + .WithFooter(new EmbedFooterBuilder() + .WithText(Strings.Embed_Page_Footer) + .WithIconUrl(Strings.InstarLogoUrl)) + // Description + .WithDescription($"```{reason}```") + .WithFields(fields); + + var embed = builder.Build(); + return embed; + } +} \ No newline at end of file diff --git a/InstarBot/Embeds/InstarReportUserEmbed.cs b/InstarBot/Embeds/InstarReportUserEmbed.cs new file mode 100644 index 0000000..673f4b3 --- /dev/null +++ b/InstarBot/Embeds/InstarReportUserEmbed.cs @@ -0,0 +1,51 @@ +using Discord; +using PaxAndromeda.Instar.Modals; +using System; + +namespace PaxAndromeda.Instar.Embeds; + +public class InstarReportUserEmbed(ReportMessageModal modal, IGuildUser contextUser, IMessage message, IInstarGuild guild) : InstarEmbed +{ + public override Embed Build() + { + var fields = new List + { + new EmbedFieldBuilder() + .WithIsInline(false) + .WithName("Message Content") + .WithValue($"```{message.Content}```"), + new EmbedFieldBuilder() + .WithIsInline(false) + .WithName("Reason") + .WithValue($"```{modal.ReportReason}```") + }; + + if (message.Author is not null) + fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("User") + .WithValue($"<@{message.Author.Id}>")); + + if (message.Channel is not null) + fields.Add(new EmbedFieldBuilder().WithIsInline(true).WithName("Channel") + .WithValue($"<#{message.Channel.Id}>")); + + fields.Add(new EmbedFieldBuilder() + .WithIsInline(true) + .WithName("Message") + .WithValue($"https://discord.com/channels/{guild.Id}/{message.Channel?.Id}/{message.Id}")); + + fields.Add(new EmbedFieldBuilder().WithIsInline(false).WithName("Reported By") + .WithValue($"<@{contextUser.Id}>")); + + var builder = new EmbedBuilder() + // Set up all the basic stuff first + .WithCurrentTimestamp() + .WithColor(0x0c94e0) + .WithAuthor(message.Author?.Username, message.Author?.GetAvatarUrl()) + .WithFooter(new EmbedFooterBuilder() + .WithText(Strings.Embed_UserReport_Footer) + .WithIconUrl(Strings.InstarLogoUrl)) + .WithFields(fields); + + return builder.Build(); + } +} \ No newline at end of file diff --git a/InstarBot/InstarBot.csproj b/InstarBot/InstarBot.csproj index 8b801ab..d7365e4 100644 --- a/InstarBot/InstarBot.csproj +++ b/InstarBot/InstarBot.csproj @@ -41,6 +41,21 @@ + + + True + True + Strings.resx + + + + + + PublicResXFileCodeGenerator + Strings.Designer.cs + + + PreserveNewest diff --git a/InstarBot/InstarBot.csproj.DotSettings b/InstarBot/InstarBot.csproj.DotSettings new file mode 100644 index 0000000..6e7fff8 --- /dev/null +++ b/InstarBot/InstarBot.csproj.DotSettings @@ -0,0 +1,2 @@ + + No \ No newline at end of file diff --git a/InstarBot/Metrics/MetricDimensionAttribute.cs b/InstarBot/Metrics/MetricDimensionAttribute.cs index 7de31d8..a126a9e 100644 --- a/InstarBot/Metrics/MetricDimensionAttribute.cs +++ b/InstarBot/Metrics/MetricDimensionAttribute.cs @@ -1,5 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + namespace PaxAndromeda.Instar.Metrics; +[ExcludeFromCodeCoverage] [AttributeUsage(AttributeTargets.Field)] public sealed class MetricDimensionAttribute(string name, string value) : Attribute { diff --git a/InstarBot/Metrics/MetricNameAttribute.cs b/InstarBot/Metrics/MetricNameAttribute.cs index 4a4414b..2247ae9 100644 --- a/InstarBot/Metrics/MetricNameAttribute.cs +++ b/InstarBot/Metrics/MetricNameAttribute.cs @@ -1,5 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + namespace PaxAndromeda.Instar.Metrics; +[ExcludeFromCodeCoverage] [AttributeUsage(AttributeTargets.Field)] public sealed class MetricNameAttribute(string name) : Attribute { diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index 79e717e..c35575c 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -401,6 +401,8 @@ public MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IG if (eligibility != MembershipEligibility.Eligible) { + // Unset Eligible flag, add NotEligible flag. + // OPTIMIZE: Do we need the NotEligible flag at all? eligibility &= ~MembershipEligibility.Eligible; eligibility |= MembershipEligibility.NotEligible; } diff --git a/InstarBot/Strings.Designer.cs b/InstarBot/Strings.Designer.cs new file mode 100644 index 0000000..660fe50 --- /dev/null +++ b/InstarBot/Strings.Designer.cs @@ -0,0 +1,495 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace PaxAndromeda.Instar { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Strings { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Strings() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("PaxAndromeda.Instar.Strings", typeof(Strings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Error while attempting to withhold membership for <@{0}>: User is already a member.. + /// + public static string Command_AutoMemberHold_Error_AlreadyMember { + get { + return ResourceManager.GetString("Command_AutoMemberHold_Error_AlreadyMember", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error while attempting to withhold membership for <@{0}>: User already has an effective membership hold.. + /// + public static string Command_AutoMemberHold_Error_AMHAlreadyExists { + get { + return ResourceManager.GetString("Command_AutoMemberHold_Error_AMHAlreadyExists", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error while attempting to withhold membership for <@{0}>: User is not a guild member.. + /// + public static string Command_AutoMemberHold_Error_NotGuildMember { + get { + return ResourceManager.GetString("Command_AutoMemberHold_Error_NotGuildMember", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error while attempting to withhold membership for <@{0}>: An unexpected error occurred while configuring the AMH.. + /// + public static string Command_AutoMemberHold_Error_Unexpected { + get { + return ResourceManager.GetString("Command_AutoMemberHold_Error_Unexpected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Membership for user <@{0}> has been withheld. Staff will be notified in one week to review.. + /// + public static string Command_AutoMemberHold_Success { + get { + return ResourceManager.GetString("Command_AutoMemberHold_Success", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error while attempting to remove auto member hold for <@{0}>: User does not have an active auto member hold.. + /// + public static string Command_AutoMemberUnhold_Error_NoActiveHold { + get { + return ResourceManager.GetString("Command_AutoMemberUnhold_Error_NoActiveHold", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error while attempting to remove auto member hold for <@{0}>: User is not a guild member.. + /// + public static string Command_AutoMemberUnhold_Error_NotGuildMember { + get { + return ResourceManager.GetString("Command_AutoMemberUnhold_Error_NotGuildMember", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error while attempting to remove auto member hold for <@{0}>: An unexpected error has occurred while removing the AMH.. + /// + public static string Command_AutoMemberUnhold_Error_Unexpected { + get { + return ResourceManager.GetString("Command_AutoMemberUnhold_Error_Unexpected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Auto member hold for user <@{0}> has been removed.. + /// + public static string Command_AutoMemberUnhold_Success { + get { + return ResourceManager.GetString("Command_AutoMemberUnhold_Success", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No, the staff will not accelerate this process by request.. + /// + public static string Command_CheckEligibility_AMH_ContactStaff { + get { + return ResourceManager.GetString("Command_CheckEligibility_AMH_ContactStaff", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your membership is currently on hold due to an administrative override. This means that you will not be granted the Member role automatically.. + /// + public static string Command_CheckEligibility_AMH_MembershipWithheld { + get { + return ResourceManager.GetString("Command_CheckEligibility_AMH_MembershipWithheld", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The staff will override an administrative hold in time when an evaluation of your behavior has been completed by the staff.. + /// + public static string Command_CheckEligibility_AMH_WhatToDo { + get { + return ResourceManager.GetString("Command_CheckEligibility_AMH_WhatToDo", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your activity has been flagged as unusual by our system. This can happen for a variety of reasons, including antisocial behavior, repeated rule violations in a short period of time, or other behavior that is deemed disruptive.. + /// + public static string Command_CheckEligibility_AMH_Why { + get { + return ResourceManager.GetString("Command_CheckEligibility_AMH_Why", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to :white_check_mark:. + /// + public static string Command_CheckEligibility_EligibleEmoji { + get { + return ResourceManager.GetString("Command_CheckEligibility_EligibleEmoji", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Membership Eligibility. + /// + public static string Command_CheckEligibility_EmbedTitle { + get { + return ResourceManager.GetString("Command_CheckEligibility_EmbedTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are already a member!. + /// + public static string Command_CheckEligibility_Error_AlreadyMember { + get { + return ResourceManager.GetString("Command_CheckEligibility_Error_AlreadyMember", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An internal error has occurred. Please try again later.. + /// + public static string Command_CheckEligibility_Error_Internal { + get { + return ResourceManager.GetString("Command_CheckEligibility_Error_Internal", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You do not have the New Member or Member roles. Please contact staff to have this corrected.. + /// + public static string Command_CheckEligibility_Error_NoMemberRoles { + get { + return ResourceManager.GetString("Command_CheckEligibility_Error_NoMemberRoles", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} **Introduction**. + /// + public static string Command_CheckEligibility_IntroductionEligibility { + get { + return ResourceManager.GetString("Command_CheckEligibility_IntroductionEligibility", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} **Join Age**. + /// + public static string Command_CheckEligibility_JoinAgeEligibility { + get { + return ResourceManager.GetString("Command_CheckEligibility_JoinAgeEligibility", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} **Messages** (last 24 hours). + /// + public static string Command_CheckEligibility_MessagesEligibility { + get { + return ResourceManager.GetString("Command_CheckEligibility_MessagesEligibility", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You have not posted an introduction in {0}.. + /// + public static string Command_CheckEligibility_MissingItem_Introduction { + get { + return ResourceManager.GetString("Command_CheckEligibility_MissingItem_Introduction", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You have not posted {0} messages in the past {1} hours.. + /// + public static string Command_CheckEligibility_MissingItem_Messages { + get { + return ResourceManager.GetString("Command_CheckEligibility_MissingItem_Messages", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You have received a warning or moderator action.. + /// + public static string Command_CheckEligibility_MissingItem_PunishmentReceived { + get { + return ResourceManager.GetString("Command_CheckEligibility_MissingItem_PunishmentReceived", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are missing {0} {1} role.. + /// + public static string Command_CheckEligibility_MissingItem_Role { + get { + return ResourceManager.GetString("Command_CheckEligibility_MissingItem_Role", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You have not been on the server for {0} hours yet.. + /// + public static string Command_CheckEligibility_MissingItem_TooYoung { + get { + return ResourceManager.GetString("Command_CheckEligibility_MissingItem_TooYoung", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} **Mod Actions**. + /// + public static string Command_CheckEligibility_ModActionsEligibility { + get { + return ResourceManager.GetString("Command_CheckEligibility_ModActionsEligibility", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Auto Member System will run <t:{0}:R>. Membership eligibility is subject to change at the time of evaluation.. + /// + public static string Command_CheckEligibility_NextRuntimeNote { + get { + return ResourceManager.GetString("Command_CheckEligibility_NextRuntimeNote", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to :x:. + /// + public static string Command_CheckEligibility_NotEligibleEmoji { + get { + return ResourceManager.GetString("Command_CheckEligibility_NotEligibleEmoji", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} **Roles**. + /// + public static string Command_CheckEligibility_RolesEligibility { + get { + return ResourceManager.GetString("Command_CheckEligibility_RolesEligibility", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to At this time, <@{0}> is eligible for membership.. + /// + public static string Command_Eligibility_EligibleText { + get { + return ResourceManager.GetString("Command_Eligibility_EligibleText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Instar encountered an error while attempting to retrieve AMH details. If the user is otherwise eligible but is not being granted membership, it is possible that they are AMHed. Please try again later.. + /// + public static string Command_Eligibility_Error_AmbiguousHold { + get { + return ResourceManager.GetString("Command_Eligibility_Error_AmbiguousHold", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to **Mod:** <@{0}>\r\n**Reason:**\r\n```{1}```\r\n**Date:** <t:{2}:f> (<t:{2}:R>). + /// + public static string Command_Eligibility_HoldFormat { + get { + return ResourceManager.GetString("Command_Eligibility_HoldFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to At this time, <@{0}> is __not__ eligible for membership.. + /// + public static string Command_Eligibility_IneligibleText { + get { + return ResourceManager.GetString("Command_Eligibility_IneligibleText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to :warning: Possible Auto Member Hold. + /// + public static string Command_Eligibility_Section_AmbiguousHold { + get { + return ResourceManager.GetString("Command_Eligibility_Section_AmbiguousHold", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to :warning: Auto Member Hold. + /// + public static string Command_Eligibility_Section_Hold { + get { + return ResourceManager.GetString("Command_Eligibility_Section_Hold", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Requirements. + /// + public static string Command_Eligibility_Section_Requirements { + get { + return ResourceManager.GetString("Command_Eligibility_Section_Requirements", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are not authorized to send a page to the entire staff team.. + /// + public static string Command_Page_Error_FullTeamNotAuthorized { + get { + return ResourceManager.GetString("Command_Page_Error_FullTeamNotAuthorized", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to send page. The 'All' team does not have a teamleader. If intended to page the owner, please select the Owner as the team.. + /// + public static string Command_Page_Error_NoAllTeamlead { + get { + return ResourceManager.GetString("Command_Page_Error_NoAllTeamlead", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are not authorized to use this command.. + /// + public static string Command_Page_Error_NotAuthorized { + get { + return ResourceManager.GetString("Command_Page_Error_NotAuthorized", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to process command due to an internal server error.. + /// + public static string Command_Page_Error_Unexpected { + get { + return ResourceManager.GetString("Command_Page_Error_Unexpected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This is a __**TEST**__ page.. + /// + public static string Command_Page_TestPageMessage { + get { + return ResourceManager.GetString("Command_Page_TestPageMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Report expired. Please try again.. + /// + public static string Command_ReportUser_ReportExpired { + get { + return ResourceManager.GetString("Command_ReportUser_ReportExpired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your report has been sent.. + /// + public static string Command_ReportUser_ReportSent { + get { + return ResourceManager.GetString("Command_ReportUser_ReportSent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Instar Auto Member System. + /// + public static string Embed_AMS_Footer { + get { + return ResourceManager.GetString("Embed_AMS_Footer", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Instar Paging System. + /// + public static string Embed_Page_Footer { + get { + return ResourceManager.GetString("Embed_Page_Footer", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Instar Message Reporting System. + /// + public static string Embed_UserReport_Footer { + get { + return ResourceManager.GetString("Embed_UserReport_Footer", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to https://spacegirl.s3.us-east-1.amazonaws.com/instar.png. + /// + public static string InstarLogoUrl { + get { + return ResourceManager.GetString("InstarLogoUrl", resourceCulture); + } + } + } +} diff --git a/InstarBot/Strings.resx b/InstarBot/Strings.resx new file mode 100644 index 0000000..70110a6 --- /dev/null +++ b/InstarBot/Strings.resx @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + No, the staff will not accelerate this process by request. + + + Your membership is currently on hold due to an administrative override. This means that you will not be granted the Member role automatically. + + + The staff will override an administrative hold in time when an evaluation of your behavior has been completed by the staff. + + + Your activity has been flagged as unusual by our system. This can happen for a variety of reasons, including antisocial behavior, repeated rule violations in a short period of time, or other behavior that is deemed disruptive. + + + :white_check_mark: + + + Membership Eligibility + Title of the embed returned when calling /checkeligibility + + + An internal error has occurred. Please try again later. + + + {0} **Introduction** + {0} is an emoji, linked to Command_CheckEligibility_EligibleEmoji and Command_CheckEligibility_NotEligibleEmoji + + + {0} **Join Age** + {0} is an emoji, linked to Command_CheckEligibility_EligibleEmoji and Command_CheckEligibility_NotEligibleEmoji + + + {0} **Messages** (last 24 hours) + {0} is an emoji, linked to Command_CheckEligibility_EligibleEmoji and Command_CheckEligibility_NotEligibleEmoji + + + You have not posted an introduction in {0}. + {0} is a link to the Introductions channel. + + + You have not posted {0} messages in the past {1} hours. + {0} is the number of messages, and {1} is the timeframe in hours they need to send them in. + + + You have received a warning or moderator action. + + + You are missing {0} {1} role. + {0} is an article matched to the value of {1}. For example, "an age role" or "a gender identity role". + + + You have not been on the server for {0} hours yet. + {0} is the number of hours pulled from config. + + + {0} **Mod Actions** + {0} is an emoji, linked to Command_CheckEligibility_EligibleEmoji and Command_CheckEligibility_NotEligibleEmoji + + + The Auto Member System will run <t:{0}:R>. Membership eligibility is subject to change at the time of evaluation. + {0} is a unix timestamp measured in seconds. + + + You do not have the New Member or Member roles. Please contact staff to have this corrected. + + + :x: + + + {0} **Roles** + {0} is an emoji, linked to Command_CheckEligibility_EligibleEmoji and Command_CheckEligibility_NotEligibleEmoji + + + Instar Auto Member System + + + You are already a member! + + + At this time, <@{0}> is eligible for membership. + {0} is the user's ID. + + + At this time, <@{0}> is __not__ eligible for membership. + {0} is the user's ID. + + + Instar encountered an error while attempting to retrieve AMH details. If the user is otherwise eligible but is not being granted membership, it is possible that they are AMHed. Please try again later. + + + :warning: Possible Auto Member Hold + + + :warning: Auto Member Hold + + + Requirements + + + Error while attempting to withhold membership for <@{0}>: User is not a guild member. + {0} is the target user ID. + + + Error while attempting to withhold membership for <@{0}>: User is already a member. + {0} is the target user ID. + + + **Mod:** <@{0}>\r\n**Reason:**\r\n```{1}```\r\n**Date:** <t:{2}:f> (<t:{2}:R>) + {0} is the moderator ID, {1} is the reason, and {2} is the unix timestamp of the issue date in seconds + + + Error while attempting to withhold membership for <@{0}>: User already has an effective membership hold. + {0} is the target user ID. + + + Error while attempting to withhold membership for <@{0}>: An unexpected error occurred while configuring the AMH. + {0} is the target user ID. + + + Membership for user <@{0}> has been withheld. Staff will be notified in one week to review. + {0} is the target user ID. + + + Error while attempting to remove auto member hold for <@{0}>: User is not a guild member. + {0} is the target user ID. + + + Error while attempting to remove auto member hold for <@{0}>: User does not have an active auto member hold. + {0} is the target user ID. + + + Error while attempting to remove auto member hold for <@{0}>: An unexpected error has occurred while removing the AMH. + {0} is the target user ID. + + + Auto member hold for user <@{0}> has been removed. + {0} is the target user ID. + + + https://spacegirl.s3.us-east-1.amazonaws.com/instar.png + + + Instar Paging System + + + You are not authorized to use this command. + + + You are not authorized to send a page to the entire staff team. + + + Failed to send page. The 'All' team does not have a teamleader. If intended to page the owner, please select the Owner as the team. + + + Failed to process command due to an internal server error. + + + This is a __**TEST**__ page. + + + Your report has been sent. + + + Report expired. Please try again. + + + Instar Message Reporting System + + \ No newline at end of file From 0691ebab29076196d9d33aa2f50e6b23d16a06ee Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Fri, 12 Dec 2025 15:40:40 -0800 Subject: [PATCH 20/53] Added birthday system implementation. --- .../Instar.dynamic.test.debug.conf.json | 44 +++ InstarBot.Tests.Common/Models/TestChannel.cs | 7 +- InstarBot.Tests.Common/Models/TestGuild.cs | 14 +- .../Models/TestGuildUser.cs | 13 +- InstarBot.Tests.Common/Models/TestMessage.cs | 75 ++++- .../Services/MockAutoMemberSystem.cs | 5 + .../Services/MockDiscordService.cs | 40 ++- .../Services/MockInstarDDBService.cs | 19 +- InstarBot.Tests.Common/TestContext.cs | 11 +- InstarBot.Tests.Common/TestUtilities.cs | 62 +++- .../AutoMemberSystemCommandTests.cs | 4 +- .../CheckEligibilityCommandTests.cs | 17 +- .../Interactions/PageCommandTests.cs | 1 - .../Interactions/ResetBirthdayCommandTests.cs | 133 ++++++++ .../Interactions/SetBirthdayCommandTests.cs | 228 +++++++++++-- .../AutoMemberSystemTests.cs | 125 ++++--- .../Services/BirthdaySystemTests.cs | 306 ++++++++++++++++++ .../AsyncAutoResetEventTests.cs | 74 +++++ InstarBot.Tests.Unit/BirthdayTests.cs | 94 ++++++ InstarBot/AsyncAutoResetEvent.cs | 82 +++++ InstarBot/AsyncEvent.cs | 4 +- InstarBot/Birthday.cs | 118 +++++++ InstarBot/Commands/AutoMemberHoldCommand.cs | 4 +- InstarBot/Commands/ResetBirthdayCommand.cs | 74 +++++ InstarBot/Commands/SetBirthdayCommand.cs | 191 +++++++---- .../Commands/TriggerBirthdaySystemCommand.cs | 21 ++ .../InstarDynamicConfiguration.cs | 24 +- .../ArbitraryDynamoDBTypeConverter.cs | 95 +++--- InstarBot/DynamoModels/InstarUserData.cs | 54 +++- .../Embeds/InstarCheckEligibilityEmbed.cs | 6 +- InstarBot/Embeds/InstarEligibilityEmbed.cs | 2 +- InstarBot/Embeds/InstarPageEmbed.cs | 1 - InstarBot/Embeds/InstarReportUserEmbed.cs | 1 - .../Embeds/InstarUnderageUserWarningEmbed.cs | 28 ++ InstarBot/MembershipEligibility.cs | 53 ++- InstarBot/Metrics/Metric.cs | 20 +- InstarBot/Program.cs | 38 ++- InstarBot/Services/AWSDynamicConfigService.cs | 12 +- InstarBot/Services/AutoMemberSystem.cs | 118 +++++-- InstarBot/Services/BirthdaySystem.cs | 276 ++++++++++++++++ InstarBot/Services/CloudwatchMetricService.cs | 110 ++++--- InstarBot/Services/DiscordService.cs | 68 +++- InstarBot/Services/FileSystemMetricService.cs | 28 ++ InstarBot/Services/GaiusAPIService.cs | 14 +- InstarBot/Services/IAutoMemberSystem.cs | 2 + InstarBot/Services/IBirthdaySystem.cs | 18 ++ InstarBot/Services/IDiscordService.cs | 15 +- InstarBot/Services/IInstarDDBService.cs | 14 + InstarBot/Services/InstarDDBService.cs | 76 ++++- InstarBot/Snowflake.cs | 3 +- InstarBot/Strings.Designer.cs | 164 ++++++++++ InstarBot/Strings.resx | 67 ++++ InstarBot/Utilities.cs | 246 +++++++------- 53 files changed, 2807 insertions(+), 512 deletions(-) create mode 100644 InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs rename InstarBot.Tests.Integration/{Interactions => Services}/AutoMemberSystemTests.cs (84%) create mode 100644 InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs create mode 100644 InstarBot.Tests.Unit/AsyncAutoResetEventTests.cs create mode 100644 InstarBot.Tests.Unit/BirthdayTests.cs create mode 100644 InstarBot/AsyncAutoResetEvent.cs create mode 100644 InstarBot/Birthday.cs create mode 100644 InstarBot/Commands/ResetBirthdayCommand.cs create mode 100644 InstarBot/Commands/TriggerBirthdaySystemCommand.cs create mode 100644 InstarBot/Embeds/InstarUnderageUserWarningEmbed.cs create mode 100644 InstarBot/Services/BirthdaySystem.cs create mode 100644 InstarBot/Services/FileSystemMetricService.cs create mode 100644 InstarBot/Services/IBirthdaySystem.cs diff --git a/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json b/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json index a8d4466..43b8301 100644 --- a/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json +++ b/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json @@ -1,6 +1,7 @@ { "Testing": true, "BotName": "Instar", + "BotUserID": "1113147583041392641", "TargetGuild": 985521318080413766, "TargetChannel": 985521318080413769, "StaffAnnounceChannel": 985521973113286667, @@ -59,6 +60,49 @@ } ] }, + "BirthdayConfig": { + "BirthdayRole": "1443067834145177750", + "BirthdayAnnounceChannel": "1114974872934809700", + "MinimumPermissibleAge": 13, + "AgeRoleMap": [ + { + "Age": 13, + "Role": "1121816062204330047" + }, + { + "Age": 14, + "Role": "1121815980818051112" + }, + { + "Age": 15, + "Role": "1121815979551363126" + }, + { + "Age": 16, + "Role": "1121815979043856484" + }, + { + "Age": 17, + "Role": "1121815977634578564" + }, + { + "Age": 18, + "Role": "1121815976837656727" + }, + { + "Age": 19, + "Role": "1121815975336104048" + }, + { + "Age": 20, + "Role": "1121815973197004880" + }, + { + "Age": 21, + "Role": "1121815941852954734" + } + ] + }, "Teams": [ { "InternalID": "ffcf94e3-3080-455a-82e2-7cd9ec7eaafd", diff --git a/InstarBot.Tests.Common/Models/TestChannel.cs b/InstarBot.Tests.Common/Models/TestChannel.cs index ea9224b..67158ef 100644 --- a/InstarBot.Tests.Common/Models/TestChannel.cs +++ b/InstarBot.Tests.Common/Models/TestChannel.cs @@ -239,7 +239,7 @@ public Task> GetActiveThreadsAsync(RequestOp public ChannelType ChannelType => throw new NotImplementedException(); - private readonly List _messages = []; + private readonly List _messages = []; public void AddMessage(IGuildUser user, string message) { @@ -248,7 +248,10 @@ public void AddMessage(IGuildUser user, string message) public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) { - throw new NotImplementedException(); + var msg = new TestMessage(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags, poll); + _messages.Add(msg); + + return Task.FromResult(msg as IUserMessage); } public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) diff --git a/InstarBot.Tests.Common/Models/TestGuild.cs b/InstarBot.Tests.Common/Models/TestGuild.cs index 5aa293d..6217a51 100644 --- a/InstarBot.Tests.Common/Models/TestGuild.cs +++ b/InstarBot.Tests.Common/Models/TestGuild.cs @@ -7,11 +7,12 @@ namespace InstarBot.Tests.Models; public class TestGuild : IInstarGuild { public ulong Id { get; init; } - public IEnumerable TextChannels { get; init; } = null!; + public IEnumerable TextChannels { get; init; } = [ ]; - public IEnumerable Roles { get; init; } = null!; - public IEnumerable Users { get; init; } = null!; + public IEnumerable Roles { get; init; } = null!; + + public List Users { get; init; } = []; public virtual ITextChannel GetTextChannel(ulong channelId) { @@ -21,5 +22,10 @@ public virtual ITextChannel GetTextChannel(ulong channelId) public virtual IRole GetRole(Snowflake roleId) { return Roles.First(n => n.Id.Equals(roleId)); - } + } + + public void AddUser(TestGuildUser user) + { + Users.Add(user); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestGuildUser.cs b/InstarBot.Tests.Common/Models/TestGuildUser.cs index 142177c..fea45a8 100644 --- a/InstarBot.Tests.Common/Models/TestGuildUser.cs +++ b/InstarBot.Tests.Common/Models/TestGuildUser.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Discord; +using Moq; #pragma warning disable CS8625 @@ -7,9 +8,9 @@ namespace InstarBot.Tests.Models; [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] [SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] -public sealed class TestGuildUser : IGuildUser +public class TestGuildUser : IGuildUser { - private readonly List _roleIds = null!; + private readonly List _roleIds = [ ]; public ulong Id { get; init; } public DateTimeOffset CreatedAt { get; set; } @@ -35,7 +36,9 @@ public sealed class TestGuildUser : IGuildUser public bool IsVideoing { get; set; } public DateTimeOffset? RequestToSpeakTimestamp { get; set; } - public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + private readonly Mock _dmChannelMock = new(); + + public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) { return string.Empty; } @@ -47,7 +50,7 @@ public string GetDefaultAvatarUrl() public Task CreateDMChannelAsync(RequestOptions options = null!) { - throw new NotImplementedException(); + return Task.FromResult(_dmChannelMock.Object); } public ChannelPermissions GetPermissions(IGuildChannel channel) @@ -159,7 +162,7 @@ public string GetAvatarDecorationUrl() public string GuildAvatarId { get; set; } = null!; public GuildPermissions GuildPermissions { get; set; } public IGuild Guild { get; set; } = null!; - public ulong GuildId { get; set; } + public ulong GuildId => TestUtilities.GuildID; public DateTimeOffset? PremiumSince { get; set; } public IReadOnlyCollection RoleIds diff --git a/InstarBot.Tests.Common/Models/TestMessage.cs b/InstarBot.Tests.Common/Models/TestMessage.cs index f79f5e4..ff675d6 100644 --- a/InstarBot.Tests.Common/Models/TestMessage.cs +++ b/InstarBot.Tests.Common/Models/TestMessage.cs @@ -1,9 +1,10 @@ using Discord; using PaxAndromeda.Instar; +using MessageProperties = Discord.MessageProperties; namespace InstarBot.Tests.Models; -public sealed class TestMessage : IMessage +public sealed class TestMessage : IUserMessage, IMessage { internal TestMessage(IUser user, string message) @@ -16,6 +17,23 @@ internal TestMessage(IUser user, string message) Content = message; } + public TestMessage(string text, bool isTTS, Embed? embed, RequestOptions? options, AllowedMentions? allowedMentions, MessageReference? messageReference, MessageComponent? components, ISticker[]? stickers, Embed[]? embeds, MessageFlags? flags, PollProperties? poll) + { + Content = text; + IsTTS = isTTS; + Flags= flags; + + var embedList = new List(); + + if (embed is not null) + embedList.Add(embed); + if (embeds is not null) + embedList.AddRange(embeds); + + Flags = flags; + Reference = messageReference; + } + public ulong Id { get; } public DateTimeOffset CreatedAt { get; } @@ -56,8 +74,9 @@ public IAsyncEnumerable> GetReactionUsersAsync(IEmote public MessageType Type => default; public MessageSource Source => default; - public bool IsTTS => false; - public bool IsPinned => false; + public bool IsTTS { get; set; } + + public bool IsPinned => false; public bool IsSuppressed => false; public bool MentionedEveryone => false; public string Content { get; } @@ -75,15 +94,59 @@ public IAsyncEnumerable> GetReactionUsersAsync(IEmote public IReadOnlyCollection MentionedUserIds => null!; public MessageActivity Activity => null!; public MessageApplication Application => null!; - public MessageReference Reference => null!; - public IReadOnlyDictionary Reactions => null!; + public MessageReference Reference { get; set; } + + public IReadOnlyDictionary Reactions => null!; public IReadOnlyCollection Components => null!; public IReadOnlyCollection Stickers => null!; - public MessageFlags? Flags => null; + public MessageFlags? Flags { get; set; } + public IMessageInteraction Interaction => null!; public MessageRoleSubscriptionData RoleSubscriptionData => null!; public PurchaseNotification PurchaseNotification => throw new NotImplementedException(); public MessageCallData? CallData => throw new NotImplementedException(); + public Task ModifyAsync(Action func, RequestOptions options = null) + { + throw new NotImplementedException(); + } + + public Task PinAsync(RequestOptions options = null) + { + throw new NotImplementedException(); + } + + public Task UnpinAsync(RequestOptions options = null) + { + throw new NotImplementedException(); + } + + public Task CrosspostAsync(RequestOptions options = null) + { + throw new NotImplementedException(); + } + + public string Resolve(TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name, TagHandling roleHandling = TagHandling.Name, + TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name) + { + throw new NotImplementedException(); + } + + public Task EndPollAsync(RequestOptions options) + { + throw new NotImplementedException(); + } + + public IAsyncEnumerable> GetPollAnswerVotersAsync(uint answerId, int? limit = null, ulong? afterId = null, + RequestOptions options = null) + { + throw new NotImplementedException(); + } + + public MessageResolvedData ResolvedData { get; set; } + public IUserMessage ReferencedMessage { get; set; } + public IMessageInteractionMetadata InteractionMetadata { get; set; } + public IReadOnlyCollection ForwardedMessages { get; set; } + public Poll? Poll { get; set; } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs b/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs index 4a6c2f0..b52aade 100644 --- a/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs +++ b/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs @@ -16,4 +16,9 @@ public virtual MembershipEligibility CheckEligibility(InstarDynamicConfiguration { throw new NotImplementedException(); } + + public Task Initialize() + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockDiscordService.cs b/InstarBot.Tests.Common/Services/MockDiscordService.cs index da1ca6e..96ad258 100644 --- a/InstarBot.Tests.Common/Services/MockDiscordService.cs +++ b/InstarBot.Tests.Common/Services/MockDiscordService.cs @@ -9,8 +9,9 @@ namespace InstarBot.Tests.Services; public sealed class MockDiscordService : IDiscordService { - private readonly IInstarGuild _guild; - private readonly AsyncEvent _userJoinedEvent = new(); + private IInstarGuild _guild; + private readonly AsyncEvent _userJoinedEvent = new(); + private readonly AsyncEvent _userLeftEvent = new(); private readonly AsyncEvent _userUpdatedEvent = new(); private readonly AsyncEvent _messageReceivedEvent = new(); private readonly AsyncEvent _messageDeletedEvent = new(); @@ -21,6 +22,12 @@ public event Func UserJoined remove => _userJoinedEvent.Remove(value); } + public event Func UserLeft + { + add => _userLeftEvent.Add(value); + remove => _userLeftEvent.Remove(value); + } + public event Func UserUpdated { add => _userUpdatedEvent.Add(value); @@ -44,6 +51,12 @@ internal MockDiscordService(IInstarGuild guild) _guild = guild; } + public IInstarGuild Guild + { + get => _guild; + set => _guild = value; + } + public Task Start(IServiceProvider provider) { return Task.CompletedTask; @@ -56,7 +69,7 @@ public IInstarGuild GetGuild() public Task> GetAllUsers() { - return Task.FromResult(((TestGuild)_guild).Users); + return Task.FromResult(((TestGuild)_guild).Users.AsEnumerable()); } public Task GetChannel(Snowflake channelId) @@ -72,6 +85,21 @@ public async IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime yield return message; } + public IGuildUser? GetUser(Snowflake snowflake) + { + return ((TestGuild) _guild).Users.FirstOrDefault(n => n.Id.Equals(snowflake.ID)); + } + + public IEnumerable GetAllUsersWithRole(Snowflake roleId) + { + return ((TestGuild) _guild).Users.Where(n => n.RoleIds.Contains(roleId.ID)); + } + + public Task SyncUsers() + { + return Task.CompletedTask; + } + public async Task TriggerUserJoined(IGuildUser user) { await _userJoinedEvent.Invoke(user); @@ -87,4 +115,10 @@ public async Task TriggerMessageReceived(IMessage message) { await _messageReceivedEvent.Invoke(message); } + + public void AddUser(TestGuildUser user) + { + var guild = _guild as TestGuild; + guild?.AddUser(user); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs index 10bb92d..0aa0446 100644 --- a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs +++ b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs @@ -44,6 +44,15 @@ public MockInstarDDBService(IEnumerable preload) public void Register(InstarUserData data) { + // Quick check to make sure we're not overriding an existing exception + try + { + _internalMock.Object.GetUserAsync(Snowflake.Generate()); + } catch + { + return; + } + _internalMock .Setup(n => n.GetUserAsync(data.UserID!)) .ReturnsAsync(new InstarDatabaseEntry(_ddbContextMock.Object, data)); @@ -73,7 +82,6 @@ public async Task> GetOrCreateUserAsync(IGui if (result is not null) return result; - // Gotta set up the mock await CreateUserAsync(InstarUserData.CreateFrom(user)); result = await _internalMock.Object.GetUserAsync(user.Id); @@ -108,7 +116,14 @@ public Task CreateUserAsync(InstarUserData data) return Task.CompletedTask; } - /// + public async Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness) + { + var result = await _internalMock.Object.GetUsersByBirthday(birthdate, fuzziness); + + return result; + } + + /// /// Configures a setup for the specified expression on the mocked interface, allowing /// control over the behavior of the mock for the given member. /// diff --git a/InstarBot.Tests.Common/TestContext.cs b/InstarBot.Tests.Common/TestContext.cs index 866f817..515ed1f 100644 --- a/InstarBot.Tests.Common/TestContext.cs +++ b/InstarBot.Tests.Common/TestContext.cs @@ -12,7 +12,7 @@ public sealed class TestContext public const ulong ChannelID = 1420070400200; public const ulong GuildID = 1420070400300; - public List UserRoles { get; init; } = []; + public HashSet UserRoles { get; init; } = []; public Action EmbedCallback { get; init; } = _ => { }; @@ -20,13 +20,16 @@ public sealed class TestContext public List GuildUsers { get; } = []; - public Dictionary Channels { get; } = []; + public Dictionary Channels { get; } = []; public Dictionary Roles { get; } = []; public Dictionary> Warnings { get; } = []; public Dictionary> Caselogs { get; } = []; - public bool InhibitGaius { get; set; } + public Dictionary> UserRolesMap { get; } = []; + + public bool InhibitGaius { get; set; } + public Mock DMChannelMock { get ; set ; } public void AddWarning(Snowflake userId, Warning warning) { @@ -50,7 +53,7 @@ public void AddChannel(Snowflake channelId) Channels.Add(channelId, new TestChannel(channelId)); } - public TestChannel GetChannel(Snowflake channelId) + public ITextChannel GetChannel(Snowflake channelId) { return Channels[channelId]; } diff --git a/InstarBot.Tests.Common/TestUtilities.cs b/InstarBot.Tests.Common/TestUtilities.cs index 9fdcace..30d14cc 100644 --- a/InstarBot.Tests.Common/TestUtilities.cs +++ b/InstarBot.Tests.Common/TestUtilities.cs @@ -23,6 +23,16 @@ public static class TestUtilities private static IConfiguration? _config; private static IDynamicConfigService? _dynamicConfig; + public static ulong GuildID + { + get + { + var cfg = GetTestConfiguration(); + + return ulong.Parse(cfg.GetValue("TargetGuild") ?? throw new ConfigurationException("Expected TargetGuild to be set")); + } + } + private static IConfiguration GetTestConfiguration() { if (_config is not null) @@ -127,7 +137,7 @@ public static void VerifyEmbed(Mock command, EmbedVerifier verifier, strin } public static void VerifyChannelMessage(Mock channel, string format, bool ephemeral = false, bool partial = false) - where T : class, ITextChannel + where T : class, IMessageChannel { channel.Verify(c => c.SendMessageAsync( It.Is(s => MatchesFormat(s, format, partial)), @@ -242,25 +252,45 @@ private static Mock SetupGuildMock(TestContext? context) return guildMock; } - public static Mock SetupUserMock(ulong userId) - where T : class, IUser - { - var userMock = new Mock(); - userMock.Setup(n => n.Id).Returns(userId); + public static Mock SetupUserMock(ulong userId) + where T : class, IUser + { + var userMock = new Mock(); + userMock.Setup(n => n.Id).Returns(userId); - return userMock; - } + return userMock; + } - private static Mock SetupUserMock(TestContext? context) - where T : class, IUser - { - var userMock = SetupUserMock(context!.UserID); + private static Mock SetupUserMock(TestContext? context) + where T : class, IUser + { + var userMock = SetupUserMock(context!.UserID); - if (typeof(T) == typeof(IGuildUser)) - userMock.As().Setup(n => n.RoleIds).Returns(context.UserRoles.Select(n => n.ID).ToList); + var dmChannelMock = new Mock(); - return userMock; - } + context.DMChannelMock = dmChannelMock; + userMock.Setup(n => n.CreateDMChannelAsync(It.IsAny())) + .ReturnsAsync(dmChannelMock.Object); + + if (typeof(T) != typeof(IGuildUser)) return userMock; + + userMock.As().Setup(n => n.RoleIds).Returns(context.UserRoles.Select(n => n.ID).ToList); + + userMock.As().Setup(n => n.AddRoleAsync(It.IsAny(), It.IsAny())) + .Callback((ulong roleId, RequestOptions _) => + { + context.UserRoles.Add(roleId); + }) + .Returns(Task.CompletedTask); + + userMock.As().Setup(n => n.RemoveRoleAsync(It.IsAny(), It.IsAny())) + .Callback((ulong roleId, RequestOptions _) => + { + context.UserRoles.Remove(roleId); + }).Returns(Task.CompletedTask); + + return userMock; + } public static Mock SetupChannelMock(ulong channelId) where T : class, IChannel diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs index 30259be..fe04992 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs @@ -27,6 +27,8 @@ private static async Task Setup(bool setupAMH = false) var mockDDB = new MockInstarDDBService(); var mockMetrics = new MockMetricService(); + + var user = new TestGuildUser { Id = userID, @@ -60,7 +62,7 @@ private static async Task Setup(bool setupAMH = false) } var commandMock = TestUtilities.SetupCommandMock( - () => new AutoMemberHoldCommand(mockDDB, TestUtilities.GetDynamicConfiguration(), mockMetrics), + () => new AutoMemberHoldCommand(mockDDB, TestUtilities.GetDynamicConfiguration(), mockMetrics, TimeProvider.System), new TestContext { UserID = modID diff --git a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs index 6fd33dc..13dc002 100644 --- a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs @@ -7,7 +7,6 @@ using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.DynamoModels; -using PaxAndromeda.Instar.Services; using Xunit; namespace InstarBot.Tests.Integration.Interactions; @@ -57,7 +56,7 @@ private static async Task> SetupCommandMock(CheckE new TestContext { UserID = userId, - UserRoles = context.Roles.Select(n => new Snowflake(n)).ToList() + UserRoles = context.Roles.Select(n => new Snowflake(n)).ToHashSet() }); context.DDB = mockDDB; @@ -181,7 +180,7 @@ public static async Task CheckEligibilityCommand_DDBError_ShouldEmitValidMessage [Theory] [InlineData(MembershipEligibility.MissingRoles)] [InlineData(MembershipEligibility.MissingIntroduction)] - [InlineData(MembershipEligibility.TooYoung)] + [InlineData(MembershipEligibility.InadequateTenure)] [InlineData(MembershipEligibility.PunishmentReceived)] [InlineData(MembershipEligibility.NotEnoughMessages)] public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMessage(MembershipEligibility eligibility) @@ -191,7 +190,7 @@ public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMes { { MembershipEligibility.MissingRoles, Strings.Command_CheckEligibility_MessagesEligibility }, { MembershipEligibility.MissingIntroduction, Strings.Command_CheckEligibility_IntroductionEligibility }, - { MembershipEligibility.TooYoung, Strings.Command_CheckEligibility_JoinAgeEligibility }, + { MembershipEligibility.InadequateTenure, Strings.Command_CheckEligibility_JoinAgeEligibility }, { MembershipEligibility.PunishmentReceived, Strings.Command_CheckEligibility_ModActionsEligibility }, { MembershipEligibility.NotEnoughMessages, Strings.Command_CheckEligibility_MessagesEligibility }, }; @@ -200,7 +199,7 @@ public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMes { { MembershipEligibility.MissingRoles, Strings.Command_CheckEligibility_MissingItem_Role }, { MembershipEligibility.MissingIntroduction, Strings.Command_CheckEligibility_MissingItem_Introduction }, - { MembershipEligibility.TooYoung, Strings.Command_CheckEligibility_MissingItem_TooYoung }, + { MembershipEligibility.InadequateTenure, Strings.Command_CheckEligibility_MissingItem_TooYoung }, { MembershipEligibility.PunishmentReceived, Strings.Command_CheckEligibility_MissingItem_PunishmentReceived }, { MembershipEligibility.NotEnoughMessages, Strings.Command_CheckEligibility_MissingItem_Messages }, }; @@ -218,7 +217,7 @@ public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMes var ctx = new CheckEligibilityCommandTestContext( false, [ NewMemberRole ], - MembershipEligibility.NotEligible | eligibility); + eligibility); var mock = await SetupCommandMock(ctx); @@ -253,7 +252,7 @@ public static async Task CheckOtherEligibility_WithEligibleMember_ShouldEmitVali public static async Task CheckOtherEligibility_WithIneligibleMember_ShouldEmitValidEmbed() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.NotEligible | MembershipEligibility.MissingRoles); + var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.MissingRoles); var mock = await SetupCommandMock(ctx); ctx.User.Should().NotBeNull(); @@ -274,7 +273,7 @@ public static async Task CheckOtherEligibility_WithIneligibleMember_ShouldEmitVa public static async Task CheckOtherEligibility_WithAMHedMember_ShouldEmitValidEmbed() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(true, [ NewMemberRole ], MembershipEligibility.NotEligible | MembershipEligibility.MissingRoles); + var ctx = new CheckEligibilityCommandTestContext(true, [ NewMemberRole ], MembershipEligibility.MissingRoles); var mock = await SetupCommandMock(ctx); ctx.User.Should().NotBeNull(); @@ -295,7 +294,7 @@ public static async Task CheckOtherEligibility_WithAMHedMember_ShouldEmitValidEm public static async Task CheckOtherEligibility_WithDynamoError_ShouldEmitValidEmbed() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.NotEligible | MembershipEligibility.MissingRoles); + var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.MissingRoles); var mock = await SetupCommandMock(ctx); ctx.DDB.Setup(n => n.GetUserAsync(It.IsAny())) diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index 1bb380c..260ae3b 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -2,7 +2,6 @@ using InstarBot.Tests.Models; using InstarBot.Tests.Services; using Moq; -using Moq.Protected; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; using Xunit; diff --git a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs new file mode 100644 index 0000000..cf5c450 --- /dev/null +++ b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs @@ -0,0 +1,133 @@ +using Discord; +using FluentAssertions; +using InstarBot.Tests.Services; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Services; +using Xunit; +using Assert = Xunit.Assert; + +namespace InstarBot.Tests.Integration.Interactions; + +public static class ResetBirthdayCommandTests +{ + private static async Task<(IInstarDDBService, Mock, IGuildUser, TestContext, InstarDynamicConfiguration cfg)> SetupMocks(Birthday? userBirthday = null, bool throwError = false, bool skipDbInsert = false) + { + TestUtilities.SetupLogging(); + + var ddbService = TestUtilities.GetServices().GetService(); + var cfgService = TestUtilities.GetDynamicConfiguration(); + var cfg = await cfgService.GetConfig(); + var userId = Snowflake.Generate(); + + if (throwError && ddbService is MockInstarDDBService mockDDB) + { + mockDDB.Setup(n => n.GetUserAsync(It.IsAny())).Throws(); + + // assert that we're actually throwing an exception + await Assert.ThrowsAsync(async () => await ddbService.GetUserAsync(userId)); + } + + var testContext = new TestContext + { + UserID = userId + }; + + var cmd = TestUtilities.SetupCommandMock(() => new ResetBirthdayCommand(ddbService!, cfgService), testContext); + + await cmd.Object.Context.User!.AddRoleAsync(cfg.NewMemberRoleID); + + cmd.Setup(n => n.Context.User!.GuildId).Returns(TestUtilities.GuildID); + + if (!skipDbInsert) + ((MockInstarDDBService) ddbService!).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); + + ddbService.Should().NotBeNull(); + + if (userBirthday is null) + return (ddbService, cmd, cmd.Object.Context.User!, testContext, cfg); + + var dbUser = await ddbService.GetUserAsync(userId); + dbUser!.Data.Birthday = userBirthday; + dbUser.Data.Birthdate = userBirthday.Key; + await dbUser.UpdateAsync(); + + return (ddbService, cmd, cmd.Object.Context.User!, testContext, cfg); + } + + [Fact] + public static async Task ResetBirthday_WithEligibleUser_ShouldHaveBirthdayReset() + { + // Arrange + var (ddb, cmd, user, ctx, _) = await SetupMocks(userBirthday: new Birthday(new DateTime(2000, 1, 1), TimeProvider.System)); + + // Act + await cmd.Object.ResetBirthday(user); + + // Assert + var dbUser = await ddb.GetUserAsync(user.Id); + dbUser.Should().NotBeNull(); + dbUser.Data.Birthday.Should().BeNull(); + dbUser.Data.Birthdate.Should().BeNull(); + + TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Success, ephemeral: true); + TestUtilities.VerifyChannelMessage(ctx.DMChannelMock, Strings.Command_ResetBirthday_EndUserNotification); + } + + [Fact] + public static async Task ResetBirthday_UserNotFound_ShouldEmitError() + { + // Arrange + var (ddb, cmd, user, ctx, _) = await SetupMocks(skipDbInsert: true); + + // Act + await cmd.Object.ResetBirthday(user); + + // Assert + var dbUser = await ddb.GetUserAsync(user.Id); + dbUser.Should().BeNull(); + + TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Error_UserNotFound, ephemeral: true); + } + + [Fact] + public static async Task ResetBirthday_UserHasBirthdayRole_ShouldRemoveRole() + { + // Arrange + var (ddb, cmd, user, ctx, cfg) = await SetupMocks(userBirthday: new Birthday(new DateTime(2000, 1, 1), TimeProvider.System)); + + await user.AddRoleAsync(cfg.BirthdayConfig.BirthdayRole); + user.RoleIds.Should().Contain(cfg.BirthdayConfig.BirthdayRole); + + // Act + await cmd.Object.ResetBirthday(user); + + // Assert + var dbUser = await ddb.GetUserAsync(user.Id); + dbUser.Should().NotBeNull(); + dbUser.Data.Birthday.Should().BeNull(); + dbUser.Data.Birthdate.Should().BeNull(); + + user.RoleIds.Should().NotContain(cfg.BirthdayConfig.BirthdayRole); + + TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Success, ephemeral: true); + TestUtilities.VerifyChannelMessage(ctx.DMChannelMock, Strings.Command_ResetBirthday_EndUserNotification); + } + + [Fact] + public static async Task ResetBirthday_WithDBError_ShouldEmitError() + { + // Arrange + var (ddb, cmd, user, ctx, _) = await SetupMocks(throwError: true); + + // Act + await cmd.Object.ResetBirthday(user); + + // Assert + TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Error_Unknown, ephemeral: true); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs index f97203a..36989a4 100644 --- a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -1,9 +1,11 @@ -using FluentAssertions; +using Discord; +using FluentAssertions; using InstarBot.Tests.Services; using Microsoft.Extensions.DependencyInjection; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; using Xunit; @@ -12,25 +14,71 @@ namespace InstarBot.Tests.Integration.Interactions; public static class SetBirthdayCommandTests { - private static (IInstarDDBService, Mock) SetupMocks(SetBirthdayContext context) + private static async Task<(IInstarDDBService, Mock, InstarDynamicConfiguration)> SetupMocks(SetBirthdayContext context, DateTime? timeOverride = null, bool throwError = false) { TestUtilities.SetupLogging(); - - var ddbService = TestUtilities.GetServices().GetService(); - var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService!, new MockMetricService()), new TestContext - { - UserID = context.User.ID - }); - - ((MockInstarDDBService) ddbService!).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); + + var timeProvider = TimeProvider.System; + if (timeOverride is not null) + { + var timeProviderMock = new Mock(); + timeProviderMock.Setup(n => n.GetUtcNow()).Returns(new DateTimeOffset((DateTime)timeOverride)); + timeProvider = timeProviderMock.Object; + } + + var staffAnnounceChannelMock = new Mock(); + var birthdayAnnounceChannelMock = new Mock(); + context.StaffAnnounceChannel = staffAnnounceChannelMock; + context.BirthdayAnnounceChannel = birthdayAnnounceChannelMock; + + var ddbService = TestUtilities.GetServices().GetService(); + var cfgService = TestUtilities.GetDynamicConfiguration(); + var cfg = await cfgService.GetConfig(); + + var guildMock = new Mock(); + guildMock.Setup(n => n.GetTextChannel(cfg.StaffAnnounceChannel)).Returns(staffAnnounceChannelMock.Object); + guildMock.Setup(n => n.GetTextChannel(cfg.BirthdayConfig.BirthdayAnnounceChannel)).Returns(birthdayAnnounceChannelMock.Object); + + var testContext = new TestContext + { + UserID = context.UserID.ID, + Channels = + { + { cfg.StaffAnnounceChannel, staffAnnounceChannelMock.Object }, + { cfg.BirthdayConfig.BirthdayAnnounceChannel, birthdayAnnounceChannelMock.Object } + } + }; + + var discord = TestUtilities.SetupDiscordService(testContext); + if (discord is MockDiscordService mockDiscord) + { + mockDiscord.Guild = guildMock.Object; + } + + var birthdaySystem = new BirthdaySystem(cfgService, discord, ddbService, new MockMetricService(), timeProvider); + + if (throwError && ddbService is MockInstarDDBService mockDDB) + mockDDB.Setup(n => n.GetOrCreateUserAsync(It.IsAny())).Throws(); + + var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService, TestUtilities.GetDynamicConfiguration(), new MockMetricService(), birthdaySystem, timeProvider), testContext); + + await cmd.Object.Context.User!.AddRoleAsync(cfg.NewMemberRoleID); + + cmd.Setup(n => n.Context.User!.GuildId).Returns(TestUtilities.GuildID); + + context.User = cmd.Object.Context.User!; + + cmd.Setup(n => n.Context.Guild).Returns(guildMock.Object); + + ((MockInstarDDBService) ddbService).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); ddbService.Should().NotBeNull(); - return (ddbService, cmd); + return (ddbService, cmd, cfg); } - [Theory(DisplayName = "User should be able to set their birthday when providing a valid date.")] + [Theory(DisplayName = "UserID should be able to set their birthday when providing a valid date.")] [InlineData(1992, 7, 21, 0)] [InlineData(1992, 7, 21, -7)] [InlineData(1992, 7, 21, 7)] @@ -42,7 +90,7 @@ public static async Task SetBirthdayCommand_WithValidDate_ShouldSetCorrectly(int // Arrange var context = new SetBirthdayContext(Snowflake.Generate(), year, month, day, timezone); - var (ddb, cmd) = SetupMocks(context); + var (ddb, cmd, _) = await SetupMocks(context); // Act await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); @@ -50,9 +98,11 @@ public static async Task SetBirthdayCommand_WithValidDate_ShouldSetCorrectly(int // Assert var date = context.ToDateTime(); - var ddbUser = await ddb.GetUserAsync(context.User.ID); - ddbUser!.Data.Birthday.Should().Be(date.UtcDateTime); - TestUtilities.VerifyMessage(cmd, $"Your birthday was set to {date.DateTime:dddd, MMMM d, yyy}.", true); + var ddbUser = await ddb.GetUserAsync(context.UserID.ID); + ddbUser!.Data.Birthday.Should().NotBeNull(); + ddbUser.Data.Birthday.Birthdate.UtcDateTime.Should().Be(date.UtcDateTime); + ddbUser.Data.Birthdate.Should().Be(date.UtcDateTime.ToString("MMddHHmm")); + TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Success, true); } [Theory(DisplayName = "Attempting to set an invalid day or month number should emit an error message.")] @@ -67,7 +117,7 @@ public static async Task SetBirthdayCommand_WithInvalidDate_ShouldReturnError(in // Arrange var context = new SetBirthdayContext(Snowflake.Generate(), year, month, day); - var (_, cmd) = SetupMocks(context); + var (_, cmd, _) = await SetupMocks(context); // Act await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); @@ -76,7 +126,7 @@ public static async Task SetBirthdayCommand_WithInvalidDate_ShouldReturnError(in if (month is < 0 or > 12) { TestUtilities.VerifyMessage(cmd, - "There are only 12 months in a year. Your birthday was not set.", true); + Strings.Command_SetBirthday_MonthsOutOfRange, true); } else { @@ -85,27 +135,133 @@ public static async Task SetBirthdayCommand_WithInvalidDate_ShouldReturnError(in // Assert TestUtilities.VerifyMessage(cmd, - $"There are only {daysInMonth} days in {date:MMMM yyy}. Your birthday was not set.", true); + Strings.Command_SetBirthday_DaysInMonthOutOfRange, true); } - } + } - [Fact(DisplayName = "Attempting to set a birthday in the future should emit an error message.")] - public static async Task SetBirthdayCommand_WithDateInFuture_ShouldReturnError() - { - // Arrange - // Note: Update this in the year 9,999 - var context = new SetBirthdayContext(Snowflake.Generate(), 9999, 1, 1); + [Fact(DisplayName = "Attempting to set a birthday in the future should emit an error message.")] + public static async Task SetBirthdayCommand_WithDateInFuture_ShouldReturnError() + { + // Arrange + // Note: Update this in the year 9,999 + var context = new SetBirthdayContext(Snowflake.Generate(), 9999, 1, 1); - var (_, cmd) = SetupMocks(context); + var (_, cmd, _) = await SetupMocks(context); - // Act - await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); + // Act + await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); - // Assert - TestUtilities.VerifyMessage(cmd, "You are not a time traveler. Your birthday was not set.", true); - } + // Assert + TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_NotTimeTraveler, true); + } + + [Fact(DisplayName = "Attempting to set a birthday when user has already set one should emit an error message.")] + public static async Task SetBirthdayCommand_BirthdayAlreadyExists_ShouldReturnError() + { + // Arrange + // Note: Update this in the year 9,999 + var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); + + var (ddb, cmd, _) = await SetupMocks(context); + + var dbUser = await ddb.GetOrCreateUserAsync(context.User); + dbUser.Data.Birthday = new Birthday(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc, TimeProvider.System); + dbUser.Data.Birthdate = dbUser.Data.Birthday.Key; + await dbUser.UpdateAsync(); + + + // Act + await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); - private record SetBirthdayContext(Snowflake User, int Year, int Month, int Day, int TimeZone = 0) + // Assert + TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Error_AlreadySet, true); + } + + [Fact(DisplayName = "An exception should return a message.")] + public static async Task SetBirthdayCommand_WithException_ShouldPromptUserToTryAgainLater() + { + // Arrange + // Note: Update this in the year 9,999 + var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); + + var (_, cmd, _) = await SetupMocks(context, throwError: true); + + // Act + await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + + // Assert + TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Error_CouldNotSetBirthday, true); + } + + [Fact(DisplayName = "Attempting to set an underage birthday should result in an AMH and staff notification.")] + public static async Task SetBirthdayCommand_WithUnderage_ShouldNotifyStaff() + { + // Arrange + // Note: Update this in the year 9,999 + var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); + + var (ddb, cmd, cfg) = await SetupMocks(context, new DateTime(2010, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + // Act + await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + + // Assert + var date = context.ToDateTime(); + + var ddbUser = await ddb.GetUserAsync(context.UserID.ID); + ddbUser!.Data.Birthday.Should().NotBeNull(); + ddbUser.Data.Birthday.Birthdate.UtcDateTime.Should().Be(date.UtcDateTime); + ddbUser.Data.Birthdate.Should().Be(date.UtcDateTime.ToString("MMddHHmm")); + + ddbUser.Data.AutoMemberHoldRecord.Should().NotBeNull(); + ddbUser.Data.AutoMemberHoldRecord!.ModeratorID.Should().Be(cfg.BotUserID); + + TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Success, true); + + var staffAnnounceChannel = cmd.Object.Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel); + staffAnnounceChannel.Should().NotBeNull(); + + + // Verify embed + var embedVerifier = EmbedVerifier.Builder() + .WithDescription(Strings.Embed_UnderageUser_WarningTemplate_NewMember).Build(); + + TestUtilities.VerifyChannelEmbed(context.StaffAnnounceChannel, embedVerifier, $"<@&{cfg.StaffRoleID}>"); + } + + [Fact(DisplayName = "Attempting to set a birthday to today should grant the birthday role.")] + public static async Task SetBirthdayCommand_BirthdayIsToday_ShouldGrantBirthdayRoles() + { + // Arrange + // Note: Update this in the year 9,999 + var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); + + var (ddb, cmd, cfg) = await SetupMocks(context, new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + // Act + await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + + // Assert + var date = context.ToDateTime(); + + context.User.RoleIds.Should().Contain(cfg.BirthdayConfig.BirthdayRole); + + var ddbUser = await ddb.GetUserAsync(context.UserID.ID); + ddbUser!.Data.Birthday.Should().NotBeNull(); + ddbUser.Data.Birthday.Birthdate.UtcDateTime.Should().Be(date.UtcDateTime); + ddbUser.Data.Birthdate.Should().Be(date.UtcDateTime.ToString("MMddHHmm")); + + ddbUser.Data.AutoMemberHoldRecord.Should().BeNull(); + + TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Success, true); + + var birthdayAnnounceChannel = cmd.Object.Context.Guild.GetTextChannel(cfg.BirthdayConfig.BirthdayAnnounceChannel); + birthdayAnnounceChannel.Should().NotBeNull(); + + TestUtilities.VerifyChannelMessage(context.BirthdayAnnounceChannel, Strings.Birthday_Announcement); + } + + private record SetBirthdayContext(Snowflake UserID, int Year, int Month, int Day, int TimeZone = 0) { public DateTimeOffset ToDateTime() { @@ -113,6 +269,10 @@ public DateTimeOffset ToDateTime() var timeZone = new DateTimeOffset(unspecifiedDate, TimeSpan.FromHours(TimeZone)); return timeZone; - } + } + + public Mock StaffAnnounceChannel { get; set; } + public IGuildUser User { get; set; } + public Mock BirthdayAnnounceChannel { get ; set ; } } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs similarity index 84% rename from InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs rename to InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs index 7f493d2..40876a5 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemTests.cs +++ b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs @@ -9,7 +9,7 @@ using PaxAndromeda.Instar.Services; using Xunit; -namespace InstarBot.Tests.Integration.Interactions; +namespace InstarBot.Tests.Integration.Services; public static class AutoMemberSystemTests { @@ -20,7 +20,7 @@ public static class AutoMemberSystemTests private static readonly Snowflake SheHer = new(796578609535647765); private static readonly Snowflake AutoMemberHold = new(966434762032054282); - private static AutoMemberSystem SetupTest(AutoMemberSystemContext scenarioContext) + private static async Task SetupTest(AutoMemberSystemContext scenarioContext) { var testContext = scenarioContext.TestContext; @@ -65,15 +65,16 @@ private static AutoMemberSystem SetupTest(AutoMemberSystemContext scenarioContex testContext.AddChannel(genericChannel); if (postedIntro) - testContext.GetChannel(amsConfig.IntroductionChannel).AddMessage(user, "Some text"); + ((TestChannel) testContext.GetChannel(amsConfig.IntroductionChannel)).AddMessage(user, "Some text"); for (var i = 0; i < messagesLast24Hours; i++) - testContext.GetChannel(genericChannel).AddMessage(user, "Some text"); + ((TestChannel)testContext.GetChannel(genericChannel)).AddMessage(user, "Some text"); - var ams = new AutoMemberSystem(config, discordService, gaiusApiService, ddbService, new MockMetricService()); + var ams = new AutoMemberSystem(config, discordService, gaiusApiService, ddbService, new MockMetricService(), TimeProvider.System); + await ams.Initialize(); - scenarioContext.User = user; + scenarioContext.User = user; scenarioContext.DynamoService = ddbService; return ams; @@ -91,9 +92,9 @@ public static async Task AutoMemberSystem_EligibleUser_ShouldBeGrantedMembership .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); - // Act + // Act await ams.RunAsync(); // Assert @@ -112,7 +113,7 @@ public static async Task AutoMemberSystem_EligibleUserWithAMH_ShouldNotBeGranted .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -133,7 +134,7 @@ public static async Task AutoMemberSystem_NewUser_ShouldNotBeGrantedMembership() .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -153,7 +154,7 @@ public static async Task AutoMemberSystem_InactiveUser_ShouldNotBeGrantedMembers .WithMessages(10) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -173,7 +174,7 @@ public static async Task AutoMemberSystem_Member_ShouldNotBeChanged() .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -192,7 +193,7 @@ public static async Task AutoMemberSystem_NoIntroduction_ShouldNotBeGrantedMembe .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -212,7 +213,7 @@ public static async Task AutoMemberSystem_NoAgeRole_ShouldNotBeGrantedMembership .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -232,7 +233,7 @@ public static async Task AutoMemberSystem_NoGenderRole_ShouldNotBeGrantedMembers .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -252,7 +253,7 @@ public static async Task AutoMemberSystem_NoPronounRole_ShouldNotBeGrantedMember .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -273,7 +274,7 @@ public static async Task AutoMemberSystem_UserWithGaiusWarning_ShouldNotBeGrante .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -282,28 +283,49 @@ public static async Task AutoMemberSystem_UserWithGaiusWarning_ShouldNotBeGrante context.AssertNotMember(); } - [Fact(DisplayName = "A user with a caselog should not be granted membership")] - public static async Task AutoMemberSystem_UserWithGaiusCaselog_ShouldNotBeGrantedMembership() - { - // Arrange - var context = await AutoMemberSystemContext.Builder() - .Joined(TimeSpan.FromHours(36)) - .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) - .HasPostedIntroduction() - .HasBeenPunished() - .WithMessages(100) - .Build(); + [Fact(DisplayName = "A user with a caselog should not be granted membership")] + public static async Task AutoMemberSystem_UserWithGaiusCaselog_ShouldNotBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .HasBeenPunished() + .WithMessages(100) + .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); - // Assert - context.AssertNotMember(); - } + // Assert + context.AssertNotMember(); + } + + [Fact(DisplayName = "A user with a join age auto kick should be granted membership")] + public static async Task AutoMemberSystem_UserWithJoinAgeKick_ShouldBeGrantedMembership() + { + // Arrange + var context = await AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(36)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .HasBeenPunished(true) + .WithMessages(100) + .Build(); - [Fact(DisplayName = "A user should be granted membership if Gaius is unavailable")] + var ams = await SetupTest(context); + + // Act + await ams.RunAsync(); + + // Assert + context.AssertMember(); + } + + [Fact(DisplayName = "A user should be granted membership if Gaius is unavailable")] public static async Task AutoMemberSystem_GaiusIsUnavailable_ShouldBeGrantedMembership() { // Arrange @@ -315,7 +337,7 @@ public static async Task AutoMemberSystem_GaiusIsUnavailable_ShouldBeGrantedMemb .WithMessages(100) .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -337,7 +359,7 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe .WithMessages(100) .Build(); - SetupTest(context); + await SetupTest(context); // Act var service = context.DiscordService as MockDiscordService; @@ -356,7 +378,7 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflectedInDynamo() { // Arrange - const string NewUsername = "fred"; + const string newUsername = "fred"; var context = await AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(1)) @@ -367,7 +389,7 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte .WithMessages(100) .Build(); - SetupTest(context); + await SetupTest(context); // Make sure the user is in the database context.DynamoService.Should().NotBeNull(because: "Test is invalid if DynamoService is not set"); @@ -377,7 +399,7 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte MockDiscordService mds = (MockDiscordService) context.DiscordService!; var newUser = context.User!.Clone(); - newUser.Username = NewUsername; + newUser.Username = newUsername; await mds.TriggerUserUpdated(new UserUpdatedEventArgs(context.UserID, context.User, newUser)); @@ -385,11 +407,11 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte var ddbUser = await context.DynamoService.GetUserAsync(context.UserID); ddbUser.Should().NotBeNull(); - ddbUser.Data.Username.Should().Be(NewUsername); + ddbUser.Data.Username.Should().Be(newUsername); ddbUser.Data.Usernames.Should().NotBeNull(); ddbUser.Data.Usernames.Count.Should().Be(2); - ddbUser.Data.Usernames.Should().Contain(n => n.Data != null && n.Data.Equals(NewUsername, StringComparison.Ordinal)); + ddbUser.Data.Usernames.Should().Contain(n => n.Data != null && n.Data.Equals(newUsername, StringComparison.Ordinal)); } [Fact(DisplayName = "A user should be created in DynamoDB if they're eligible for membership but missing in DDB")] @@ -405,7 +427,7 @@ public static async Task AutoMemberSystem_MemberEligibleButMissingInDDB_ShouldBe .SuppressDDBEntry() .Build(); - var ams = SetupTest(context); + var ams = await SetupTest(context); // Act await ams.RunAsync(); @@ -459,9 +481,10 @@ private class AutoMemberSystemContextBuilder private Snowflake[]? _roles; private bool _postedIntroduction; private int _messagesLast24Hours; - private bool _gaiusAvailable = true; - private bool _gaiusPunished; - private bool _gaiusWarned; + private bool _gaiusAvailable = true; + private bool _gaiusPunished; + private bool _joinAgeKick; + private bool _gaiusWarned; private int _firstJoinTime; private bool _grantedMembershipBefore; private bool _suppressDDB; @@ -496,10 +519,12 @@ public AutoMemberSystemContextBuilder InhibitGaius() return this; } - public AutoMemberSystemContextBuilder HasBeenPunished() + public AutoMemberSystemContextBuilder HasBeenPunished(bool isJoinAgeKick = false) { _gaiusPunished = true; - return this; + _joinAgeKick = isJoinAgeKick; + + return this; } public AutoMemberSystemContextBuilder HasBeenWarned() @@ -541,8 +566,8 @@ public async Task Build() { testContext.AddCaselog(userId, new Caselog { - Type = CaselogType.Mute, - Reason = "TEST PUNISHMENT", + Type = _joinAgeKick ? CaselogType.Kick : CaselogType.Mute, + Reason = _joinAgeKick ? "Join age punishment" : "TEST PUNISHMENT", ModID = Snowflake.Generate(), UserID = userId }); diff --git a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs new file mode 100644 index 0000000..6ef494e --- /dev/null +++ b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs @@ -0,0 +1,306 @@ +using Amazon.DynamoDBv2.DataModel; +using FluentAssertions; +using InstarBot.Tests.Models; +using InstarBot.Tests.Services; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Services; +using Xunit; +using Metric = PaxAndromeda.Instar.Metrics.Metric; + +namespace InstarBot.Tests.Integration.Services; + +public static class BirthdaySystemTests +{ + private static async Task Setup(DateTime todayLocal, DateTimeOffset? birthdate = null, bool applyBirthday = false, Func, InstarDynamicConfiguration, List>? roleUpdateFn = null) + { + var today = todayLocal.ToUniversalTime(); + var timeProviderMock = new Mock(); + timeProviderMock.Setup(n => n.GetUtcNow()).Returns(today); + + Birthday? birthday = birthdate is null ? null : new Birthday(birthdate.Value, timeProviderMock.Object); + + var testUserId = Snowflake.Generate(); + var cfg = TestUtilities.GetDynamicConfiguration(); + var mockDDB = new MockInstarDDBService(); + var metrics = new MockMetricService(); + var polledCfg = await cfg.GetConfig(); + + var discord = TestUtilities.SetupDiscordService(new TestContext + { + UserID = Snowflake.Generate(), + Channels = + { + { polledCfg.BirthdayConfig.BirthdayAnnounceChannel, new TestChannel(polledCfg.BirthdayConfig.BirthdayAnnounceChannel) } + } + }) as MockDiscordService; + + discord.Should().NotBeNull(); + + var user = new TestGuildUser + { + Id = testUserId, + Username = "TestUser" + }; + + var rolesToAdd = new List(); // Some random role + + if (applyBirthday) + rolesToAdd.Add(polledCfg.BirthdayConfig.BirthdayRole); + + if (birthday is not null) + { + int priorYearsOld = birthday.Age - 1; + var ageRole = polledCfg.BirthdayConfig.AgeRoleMap + .OrderByDescending(n => n.Age) + .SkipWhile(n => n.Age > priorYearsOld) + .First(); + + rolesToAdd.Add(ageRole.Role.ID); + } + + if (roleUpdateFn is not null) + rolesToAdd = roleUpdateFn(rolesToAdd, polledCfg); + + await user.AddRolesAsync(rolesToAdd); + + + discord.AddUser(user); + + var dbUser = InstarUserData.CreateFrom(user); + + if (birthday is not null) + { + dbUser.Birthday = birthday; + dbUser.Birthdate = birthday.Key; + + if (birthday.IsToday) + { + mockDDB.Setup(n => n.GetUsersByBirthday(today, It.IsAny())) + .ReturnsAsync([new InstarDatabaseEntry(Mock.Of(), dbUser)]); + } + } + + await mockDDB.CreateUserAsync(dbUser); + + + + var birthdaySystem = new BirthdaySystem(cfg, discord, mockDDB, metrics, timeProviderMock.Object); + + return new Context(testUserId, birthdaySystem, mockDDB, discord, metrics, polledCfg, birthday); + } + + private static bool IsDateMatch(DateTime a, DateTime b) + { + // Match everything but year + var aUtc = a.ToUniversalTime(); + var bUtc = b.ToUniversalTime(); + + return aUtc.Month == bUtc.Month && aUtc.Day == bUtc.Day && aUtc.Hour == bUtc.Hour; + } + + [Fact] + public static async Task BirthdaySystem_WhenUserBirthday_ShouldGrantRole() + { + // Arrange + var birthday = DateTime.Parse("2000-02-14T00:00:00Z"); + var today = DateTime.Parse("2025-02-14T00:00:00Z"); + + var ctx = await Setup(today, birthday); + + // Act + await ctx.System.RunAsync(); + + // Assert + // We expect a few things here: the test user should now have the birthday + // role, and there should now be a message in the birthday announce channel. + + var user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.AgeRoleMap.MaxBy(n => n.Age)!.Role.ID); + + var channel = await ctx.Discord.GetChannel(ctx.Cfg.BirthdayConfig.BirthdayAnnounceChannel) as TestChannel; + channel.Should().NotBeNull(); + + var messages = await channel.GetMessagesAsync().SelectMany(n => n).ToListAsync(); + messages.Count.Should().BeGreaterThan(0); + TestUtilities.MatchesFormat(Strings.Birthday_Announcement, messages[0].Content); + + ctx.Metrics.GetMetricValues(Metric.BirthdaySystem_Failures).Sum().Should().Be(0); + ctx.Metrics.GetMetricValues(Metric.BirthdaySystem_Grants).Sum().Should().Be(1); + } + + [Fact] + public static async Task BirthdaySystem_WhenUserBirthday_ShouldUpdateAgeRoles() + { + // Arrange + var birthdate = DateTime.Parse("2000-02-14T00:00:00Z"); + var today = DateTime.Parse("2016-02-14T00:00:00Z"); + + var ctx = await Setup(today, birthdate); + + int yearsOld = ctx.Birthday.Age; + + var priorAgeSnowflake = ctx.Cfg.BirthdayConfig.AgeRoleMap.First(n => n.Age == yearsOld - 1).Role; + var newAgeSnowflake = ctx.Cfg.BirthdayConfig.AgeRoleMap.First(n => n.Age == yearsOld).Role; + + // Preassert + var user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().Contain(priorAgeSnowflake); + user.RoleIds.Should().NotContain(newAgeSnowflake); + + + // Act + await ctx.System.RunAsync(); + + // Assert + // The main thing we're looking for in this test is whether the previous age + // role was removed and the new one applied. + user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().NotContain(priorAgeSnowflake); + user.RoleIds.Should().Contain(newAgeSnowflake); + } + + [Fact] + public static async Task BirthdaySystem_WhenUserBirthdayWithNoYear_ShouldNotUpdateAgeRoles() + { + // Arrange + var birthday = DateTime.Parse("1600-02-14T00:00:00Z"); + var today = DateTime.Parse("2016-02-14T00:00:00Z"); + + var ctx = await Setup(today, birthday, roleUpdateFn: (_, cfg) => + { + // just return the 16 age role + return [ cfg.BirthdayConfig.AgeRoleMap.First(n => n.Age == 16).Role.ID ]; + }); + + // Preassert + var user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + + user.RoleIds.Should().ContainSingle(); + var priorRoleId = user.RoleIds.First(); + + // Act + await ctx.System.RunAsync(); + + // Assert + user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + // Since the user's birth year isn't set, we can't actually calculate their age, + // so we expect that their age roles won't be changed. + user.RoleIds.Should().HaveCount(2); + user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + user.RoleIds.Should().Contain(priorRoleId); + } + + [Fact] + public static async Task BirthdaySystem_WhenNoBirthdays_ShouldDoNothing() + { + // Arrange + var birthday = DateTime.Parse("2000-02-14T00:00:00Z"); + var today = DateTime.Parse("2025-02-17T00:00:00Z"); + + var ctx = await Setup(today, birthday); + + // Act + await ctx.System.RunAsync(); + + // Assert + var user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().NotContain(ctx.Cfg.BirthdayConfig.BirthdayRole); + + var channel = await ctx.Discord.GetChannel(ctx.Cfg.BirthdayConfig.BirthdayAnnounceChannel) as TestChannel; + channel.Should().NotBeNull(); + + var messages = await channel.GetMessagesAsync().SelectMany(n => n).ToListAsync(); + messages.Count.Should().Be(0); + } + + [Fact] + public static async Task BirthdaySystem_WithUserHavingOldBirthday_ShouldRemoveOldBirthdayRoles() + { + // Arrange + var birthday = DateTime.Parse("2000-02-13T00:00:00Z"); + var today = DateTime.Parse("2025-02-14T00:00:00Z"); + + var ctx = await Setup(today, birthday, true); + + // Pre assert + var user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + + + // Act + await ctx.System.RunAsync(); + + // Assert + user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().NotContain(ctx.Cfg.BirthdayConfig.BirthdayRole); + } + + [Fact] + public static async Task BirthdaySystem_WithBirthdayRoleButNoBirthdayRecord_ShouldRemoveBirthdayRoles() + { + // Arrange + var birthday = DateTime.Parse("2000-02-13T00:00:00Z"); + var today = DateTime.Parse("2025-02-14T00:00:00Z"); + + var ctx = await Setup(today, applyBirthday: true); + + // Pre assert + var user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + + + // Act + await ctx.System.RunAsync(); + + // Assert + user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().NotContain(ctx.Cfg.BirthdayConfig.BirthdayRole); + } + + [Fact] + public static async Task BirthdaySystem_WithUserBirthdayStill_ShouldKeepBirthdayRoles() + { + // Arrange + var birthday = DateTime.Parse("2000-02-13T12:00:00Z"); + var today = DateTime.Parse("2025-02-14T00:00:00Z"); + + var ctx = await Setup(today, birthday, true); + + // Pre assert + var user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + + // Act + await ctx.System.RunAsync(); + + // Assert + user = ctx.Discord.GetUser(ctx.TestUserId); + user.Should().NotBeNull(); + user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + } + + private record Context( + Snowflake TestUserId, + BirthdaySystem System, + MockInstarDDBService DDB, + MockDiscordService Discord, + MockMetricService Metrics, + InstarDynamicConfiguration Cfg, + Birthday? Birthday + ); +} \ No newline at end of file diff --git a/InstarBot.Tests.Unit/AsyncAutoResetEventTests.cs b/InstarBot.Tests.Unit/AsyncAutoResetEventTests.cs new file mode 100644 index 0000000..de0ac0c --- /dev/null +++ b/InstarBot.Tests.Unit/AsyncAutoResetEventTests.cs @@ -0,0 +1,74 @@ +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using PaxAndromeda.Instar; +using Xunit; + +namespace InstarBot.Tests; + +public class AsyncAutoResetEventTests +{ + [Fact] + public void WaitAsync_CompletesImmediately_WhenInitiallySignaled() + { + // Arrange + var ev = new AsyncAutoResetEvent(true); + + // Act + var task = ev.WaitAsync(); + + // Assert + task.IsCompleted.Should().BeTrue(); + } + + [Fact] + public void Set_ReleasesSingleWaiter() + { + var ev = new AsyncAutoResetEvent(false); + + var waiter1 = ev.WaitAsync(); + var waiter2 = ev.WaitAsync(); + + // First Set should release only one waiter. + ev.Set(); + + // Ensure waiter1 has completed, but waiter2 is not + waiter1.IsCompleted.Should().BeTrue(); + waiter2.IsCompleted.Should().BeFalse(); + + // Second Set should release the remaining waiter. + ev.Set(); + waiter2.IsCompleted.Should().BeTrue(); + } + + [Fact] + public void Set_MarksEventSignaled_WhenNoWaiters() + { + var ev = new AsyncAutoResetEvent(false); + + // No waiters now — Set should mark the event signaled so the next WaitAsync completes immediately. + ev.Set(); + + var immediate = ev.WaitAsync(); + + // WaitAsync should complete immediately after Set() when there were no waiters + immediate.IsCompleted.Should().BeTrue(); + + // That consumption should reset the event; a subsequent waiter should block. + var next = ev.WaitAsync(); + + next.IsCompleted.Should().BeFalse(); + } + + [Fact] + public async Task WaitAsync_Cancels_WhenCancellationRequested() + { + var ev = new AsyncAutoResetEvent(false); + using var cts = new CancellationTokenSource(); + + var task = ev.WaitAsync(cts.Token); + await cts.CancelAsync(); + + await Assert.ThrowsAsync(async () => await task); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Unit/BirthdayTests.cs b/InstarBot.Tests.Unit/BirthdayTests.cs new file mode 100644 index 0000000..41303e4 --- /dev/null +++ b/InstarBot.Tests.Unit/BirthdayTests.cs @@ -0,0 +1,94 @@ +using System; +using FluentAssertions; +using Moq; +using PaxAndromeda.Instar; +using Xunit; + +namespace InstarBot.Tests; + +public class BirthdayTests +{ + private TimeProvider GetTimeProvider(DateTimeOffset dateTime) + { + var mock = new Mock(); + mock.Setup(n => n.GetUtcNow()).Returns(dateTime.UtcDateTime); + + return mock.Object; + } + + [Theory] + [InlineData("2025-08-01T00:00:00Z", "1992-07-01T00:00:00Z", 33)] // after birthday + [InlineData("2025-07-01T00:00:00Z", "1992-07-01T00:00:00Z", 33)] // on birthday + [InlineData("2025-06-01T00:00:00Z", "1992-07-01T00:00:00Z", 32)] // before birthday + [InlineData("2025-02-14T00:00:00Z", "1992-02-29T00:00:00Z", 32)] // leap year Birthdate before birthday + [InlineData("2025-03-01T00:00:00Z", "1992-02-29T00:00:00Z", 33)] // leap year Birthdate after birthday + public void Age_ShouldReturnExpectedAge(string currentDateStr, string birthDateStr, int expectedAge) + { + var timeProvider = GetTimeProvider(DateTime.Parse(currentDateStr)); + var birthDate = DateTime.Parse(birthDateStr).ToUniversalTime(); + + var birthday = new Birthday(birthDate, timeProvider); + + birthday.Age.Should().Be(expectedAge); + } + + [Theory] + [InlineData("2025-08-01T12:00:00-07:00", "2025-08-01T13:00:00-07:00", true)] + [InlineData("2025-08-01T12:00:00-07:00", "2025-08-02T13:00:00-07:00", false)] + [InlineData("2025-08-01T12:00:00Z", "2025-08-01T12:00:00-07:00", true)] + [InlineData("2025-02-28T12:00:00Z", "2024-02-29T12:00:00Z", true)] + public void IsToday_ShouldReturnExpected(string currentUtc, string testTime, bool expected) + { + var timeProvider = GetTimeProvider(DateTime.Parse(currentUtc)); + var birthDate = DateTime.Parse(testTime).ToUniversalTime(); + + var birthday = new Birthday(birthDate, timeProvider); + + birthday.IsToday.Should().Be(expected); + } + + [Theory] + [InlineData("2000-07-01T00:00:00Z", "07010000")] + [InlineData("2001-07-01T00:00:00Z", "07010000")] + [InlineData("2000-07-01T00:00:00-08:00", "07010800")] + [InlineData("2000-08-01T00:00:00Z", "08010000")] + [InlineData("2000-02-29T00:00:00Z", "02290000")] + public void Key_ShouldReturnExpectedDatabaseKey(string date, string expectedKey) + { + var timeProvider = GetTimeProvider(DateTime.Parse(date)); + var birthDate = DateTime.Parse(date).ToUniversalTime(); + + var birthday = new Birthday(birthDate, timeProvider); + + birthday.Key.Should().Be(expectedKey); + } + + [Theory] + [InlineData("2000-07-01T00:00:00Z", 962409600)] // normal date + [InlineData("1970-01-01T00:00:00Z", 0)] // epoch + [InlineData("1969-12-31T23:59:59Z", -1)] // just before epoch + [InlineData("1900-01-01T00:00:00Z", -2208988800)] // far before epoch + public void Timestamp_ShouldReturnExpectedTimestamp(string date, long expectedTimestamp) + { + var birthDate = DateTime.Parse(date).ToUniversalTime(); + + var birthday = new Birthday(birthDate, TimeProvider.System); + + birthday.Timestamp.Should().Be(expectedTimestamp); + } + + [Theory] + [InlineData("2025-06-15T00:00:00Z", "1990-07-01T00:00:00Z", "2025-07-01T00:00:00Z")] // birthday later in year + [InlineData("2025-08-15T00:00:00Z", "1990-07-01T00:00:00Z", "2025-07-01T00:00:00Z")] // birthday earlier in year + [InlineData("2024-02-15T00:00:00Z", "1992-02-29T00:00:00Z", "2024-02-29T00:00:00Z")] // leap year birthday on leap year + [InlineData("2025-02-15T00:00:00Z", "1992-02-29T00:00:00Z", "2025-02-28T00:00:00Z")] // leap year birthday on non-leap year + public void Observed_ShouldReturnThisYearsBirthday(string currentTime, string birthdate, string expectedObservedDate) + { + var timeProvider = GetTimeProvider(DateTime.Parse(currentTime)); + var birthday = new Birthday(DateTimeOffset.Parse(birthdate), timeProvider); + + var expectedTime = DateTimeOffset.Parse(expectedObservedDate); + + birthday.Observed.Should().Be(expectedTime); + } +} \ No newline at end of file diff --git a/InstarBot/AsyncAutoResetEvent.cs b/InstarBot/AsyncAutoResetEvent.cs new file mode 100644 index 0000000..ce92c56 --- /dev/null +++ b/InstarBot/AsyncAutoResetEvent.cs @@ -0,0 +1,82 @@ +namespace PaxAndromeda.Instar; + +/// +/// Provides an asynchronous auto-reset event that allows tasks to wait for a signal and ensures +/// that only one waiting task is released per signal. +/// +/// +/// AsyncAutoResetEvent enables coordination between asynchronous operations by allowing tasks to wait +/// until the event is signaled. When Set is called, only one waiting task is released; subsequent calls +/// to WaitAsync will wait until the event is signaled again. This class is thread-safe and can be used +/// in scenarios where asynchronous signaling is required, such as implementing producer-consumer +/// patterns or throttling access to resources. +/// +/// +/// True to initialize the event in the signaled state so that the first call to WaitAsync completes +/// immediately; otherwise, false to initialize in the non-signaled state. +/// +public sealed class AsyncAutoResetEvent(bool signaled) +{ + private readonly Queue _queue = new(); + + private bool _signaled = signaled; + + /// + /// Asynchronously waits until the signal is set or the operation is canceled. + /// + /// If the signal is already set when this method is called, the returned task completes immediately. + /// Multiple callers may wait concurrently; only one will be released per signal. This method is thread-safe. + /// A cancellation token that can be used to cancel the wait operation before the signal is set. If cancellation is + /// requested, the returned task will be canceled. + /// A task that completes when the signal is set or is canceled if the provided cancellation token is triggered. + public Task WaitAsync(CancellationToken cancellationToken = default) + { + lock (_queue) + { + if (_signaled) + { + _signaled = false; + return Task.CompletedTask; + } + else + { + var tcs = new TaskCompletionSource(); + if (cancellationToken.CanBeCanceled) + { + // If the token is cancelled, cancel the waiter. + var registration = cancellationToken.Register(() => tcs.TrySetCanceled(), useSynchronizationContext: false); + + // If the waiter completes or faults, unregister our interest in cancellation. + tcs.Task.ContinueWith( + _ => registration.Unregister(), + cancellationToken, + TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuationOptions.NotOnFaulted, + TaskScheduler.Default); + } + _queue.Enqueue(tcs); + return tcs.Task; + } + } + } + + /// + /// Signals the event, allowing one waiting operation to proceed or marking the event as signaled if no operations are + /// waiting. + /// + /// If there are pending operations waiting for the event, one will be released. If no operations are + /// waiting, the event remains signaled until a future wait occurs. This method is thread-safe and can be called from + /// multiple threads. + public void Set() + { + TaskCompletionSource? toRelease = null; + + lock (_queue) + if (_queue.Count > 0) + toRelease = _queue.Dequeue(); + else if (!_signaled) + _signaled = true; + + // It's possible that the TCS has already been cancelled. + toRelease?.TrySetResult(); + } +} \ No newline at end of file diff --git a/InstarBot/AsyncEvent.cs b/InstarBot/AsyncEvent.cs index 5c6bc33..4b65bbe 100644 --- a/InstarBot/AsyncEvent.cs +++ b/InstarBot/AsyncEvent.cs @@ -24,14 +24,14 @@ public async Task Invoke(T parameter) public void Add(Func subscriber) { - Guard.Against.Null(subscriber, nameof(subscriber)); + Guard.Against.Null(subscriber); lock (_subLock) _subscriptions = _subscriptions.Add(subscriber); } public void Remove(Func subscriber) { - Guard.Against.Null(subscriber, nameof(subscriber)); + Guard.Against.Null(subscriber); lock (_subLock) _subscriptions = _subscriptions.Remove(subscriber); } diff --git a/InstarBot/Birthday.cs b/InstarBot/Birthday.cs new file mode 100644 index 0000000..5df8cf5 --- /dev/null +++ b/InstarBot/Birthday.cs @@ -0,0 +1,118 @@ +using System.Diagnostics.CodeAnalysis; + +namespace PaxAndromeda.Instar; + +/// +/// Represents a person's birthday, providing methods to calculate the observed birthday, age, and related information +/// based on a specified date and time provider. +/// +/// The date and time of birth, including the time zone offset. +/// An object that supplies the current date and time. If not specified, the system time provider is used. +public record Birthday(DateTimeOffset Birthdate, TimeProvider TimeProvider) +{ + /// + /// Gets the date of the next observed birthday based on the current year. + /// + /// + /// For most scenarios, this is the date in the current year with the same month and day + /// as the user's Birthdate. However, for users born on February 29th, this will be defined + /// as the day immediately preceding March 1st in non-leap years (i.e., February 28th). + /// + public DateTimeOffset Observed + { + get + { + var birthdayNormalized = Normalize(Birthdate); + var now = TimeProvider.GetUtcNow().ToOffset(birthdayNormalized.Offset); + + return new DateTimeOffset(now.Year, birthdayNormalized.Month, birthdayNormalized.Day, + birthdayNormalized.Hour, birthdayNormalized.Minute, birthdayNormalized.Second, + birthdayNormalized.Offset); + } + } + + /// + /// Gets the age, in years, calculated from the Birthdate to the current date. + /// + /// + /// To accurately determine the age of users born on February 29th, this calculation + /// will use the normalized Birthdate for the given year, which will be defined as + /// the day immediately preceding March 1st in non-leap years (i.e., February 28th). + /// + public int Age + { + get + { + var now = TimeProvider.GetUtcNow().ToOffset(Birthdate.Offset); + + // Preliminary age based on year difference + int age = now.Year - Birthdate.Year; + + // If the test date is before the anniversary this year, they haven't had their birthday yet + if (now < Observed) + age--; + + return age; + } + } + + /// + /// Gets a value indicating whether the observed date and time occur on the current day in the observed time zone. + /// + /// This property compares the date portion of the observed time with the current date, adjusted to the + /// same time zone offset as the observed value. It returns if the observed time falls within + /// the range of the current local day; otherwise, . + public bool IsToday + { + get + { + var dtNow = TimeProvider.GetUtcNow(); + + // Convert current UTC time to the same offset as localTime + var utcOffset = Observed.Offset; + var currentLocalTime = dtNow.ToOffset(utcOffset); + + var localTimeToday = new DateTimeOffset(currentLocalTime.Date, currentLocalTime.Offset); + var localTimeTomorrow = localTimeToday.Date.AddDays(1); + + return Observed >= localTimeToday && Observed < localTimeTomorrow; + } + } + + /// + /// Returns a string key representing the specified Birthdate in UTC, formatted as month, day, hour, and minute. + /// + /// This method is useful for creating compact, sortable keys based on Birthdate and time. The returned + /// string uses UTC time to ensure consistency across time zones. + public string Key => Utilities.ToBirthdateKey(Birthdate); + + /// + /// Gets the Unix timestamp representing the number of seconds that have elapsed since 00:00:00 UTC on 1 January 1970 + /// (the Unix epoch) for the associated Birthdate. + /// + public long Timestamp => Birthdate.ToUnixTimeSeconds(); + + [ExcludeFromCodeCoverage] + public Birthday(int year, int month, int day, int hour, int minute, int second, DateTimeKind kind, TimeProvider provider) + : this(new DateTimeOffset(new DateTime(year, month, day, hour, minute, second, kind)), provider) + { } + + [ExcludeFromCodeCoverage] + public Birthday(int year, int month, int day, int hour, int minute, int second, TimeSpan offset, TimeProvider provider) + : this(new DateTimeOffset(year, month, day, hour, minute, second, offset), provider) + { } + + private DateTimeOffset Normalize(DateTimeOffset dto) + { + // Return as is if the year is a leap year + if (DateTime.IsLeapYear(TimeProvider.GetUtcNow().ToOffset(Birthdate.Offset).Year)) + return dto; + + return dto is not { Month: 2, Day: 29 } + // Return as is if the date is not Feb 29 + ? dto + + // Default to Feb 28 on non-leap years. Sorry Feb 29 babies. + : new DateTimeOffset(dto.Year, 2, 28, dto.Hour, dto.Minute, dto.Second, dto.Offset); + } +} \ No newline at end of file diff --git a/InstarBot/Commands/AutoMemberHoldCommand.cs b/InstarBot/Commands/AutoMemberHoldCommand.cs index daa858f..7bc606d 100644 --- a/InstarBot/Commands/AutoMemberHoldCommand.cs +++ b/InstarBot/Commands/AutoMemberHoldCommand.cs @@ -11,7 +11,7 @@ namespace PaxAndromeda.Instar.Commands; [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class AutoMemberHoldCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfigService, IMetricService metricService) : BaseCommand +public class AutoMemberHoldCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfigService, IMetricService metricService, TimeProvider timeProvider) : BaseCommand { [UsedImplicitly] [SlashCommand("amh", "Withhold automatic membership grants to a user.")] @@ -26,7 +26,7 @@ string reason Guard.Against.Null(Context.User); Guard.Against.Null(user); - var date = DateTime.UtcNow; + var date = timeProvider.GetUtcNow().UtcDateTime; Snowflake modId = Context.User.Id; try diff --git a/InstarBot/Commands/ResetBirthdayCommand.cs b/InstarBot/Commands/ResetBirthdayCommand.cs new file mode 100644 index 0000000..44914d6 --- /dev/null +++ b/InstarBot/Commands/ResetBirthdayCommand.cs @@ -0,0 +1,74 @@ +using Discord; +using Discord.Interactions; +using JetBrains.Annotations; +using PaxAndromeda.Instar.Preconditions; +using PaxAndromeda.Instar.Services; +using Serilog; + +namespace PaxAndromeda.Instar.Commands; + +public class ResetBirthdayCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfig) : BaseCommand +{ + /* + * Concern: This command runs very slowly, and might end up hitting the 3-second limit from Discord. + * If we start seeing timeouts, we may need to make the end user notification asynchronous. + */ + [UsedImplicitly] + [SlashCommand("resetbirthday", "Resets a user's birthday, allowing them to set it again.")] + [RequireStaffMember] + public async Task ResetBirthday( + [Summary("user", "The user to reset the birthday of.")] + IUser user + ) + { + try + { + var dbUser = await ddbService.GetUserAsync(user.Id); + + if (dbUser is null) + { + await RespondAsync(string.Format(Strings.Command_ResetBirthday_Error_UserNotFound, user.Id), ephemeral: true); + return; + } + + dbUser.Data.Birthday = null; + dbUser.Data.Birthdate = null; + await dbUser.UpdateAsync(); + + if (user is IGuildUser guildUser) + { + var cfg = await dynamicConfig.GetConfig(); + + if (guildUser.RoleIds.Contains(cfg.BirthdayConfig.BirthdayRole)) + { + try + { + await guildUser.RemoveRoleAsync(cfg.BirthdayConfig.BirthdayRole); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to remove birthday role from user {UserID}", user.Id); + + await RespondAsync(string.Format(Strings.Command_ResetBirthday_Error_RemoveBirthdayRole, user.Id)); + return; + } + } + + try + { + await guildUser.SendMessageAsync(Strings.Command_ResetBirthday_EndUserNotification); + } catch (Exception dmEx) + { + Log.Error(dmEx, "Failed to send a DM to user {UserID}", user.Id); + // ignore + } + } + + await RespondAsync(string.Format(Strings.Command_ResetBirthday_Success, user.Id), ephemeral: true); + } catch (Exception ex) + { + Log.Error(ex, "Failed to reset the birthday of {UserID}", user.Id); + await RespondAsync(string.Format(Strings.Command_ResetBirthday_Error_Unknown, user.Id), ephemeral: true); + } + } +} \ No newline at end of file diff --git a/InstarBot/Commands/SetBirthdayCommand.cs b/InstarBot/Commands/SetBirthdayCommand.cs index 677e619..af2d894 100644 --- a/InstarBot/Commands/SetBirthdayCommand.cs +++ b/InstarBot/Commands/SetBirthdayCommand.cs @@ -1,95 +1,143 @@ -using System.Diagnostics.CodeAnalysis; -using Discord; +using Discord; using Discord.Interactions; using Discord.WebSocket; using JetBrains.Annotations; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Embeds; using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Serilog; +using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Commands; // Required to be unsealed for mocking [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class SetBirthdayCommand(IInstarDDBService ddbService, IMetricService metricService) : BaseCommand +public class SetBirthdayCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfig, IMetricService metricService, IBirthdaySystem birthdaySystem, TimeProvider timeProvider) : BaseCommand { - [UsedImplicitly] + /// + /// The default year to use when none is provided. We select a year that is sufficiently + /// far in the past to be obviously unset, as well as a leap year to accommodate February 29th birthdays. + /// + /// DISCLAIMER: We are not actually asserting that a user that does not provide a year is 425 years old. + /// + private const int DefaultYear = 1600; + + [UsedImplicitly] [SlashCommand("setbirthday", "Sets your birthday on the server.")] - public async Task SetBirthday( - [MinValue(1)] [MaxValue(12)] [Summary(description: "The month you were born.")] - Month month, - [MinValue(1)] [MaxValue(31)] [Summary(description: "The day you were born.")] - int day, - [MinValue(1900)] [MaxValue(2099)] [Summary(description: "The year you were born.")] - int year, - [MinValue(-12)] - [MaxValue(+12)] - [Summary("timezone", "Select your nearest time zone offset in hours from GMT.")] - [Autocomplete] - int tzOffset = 0) - { - if (Context.User is null) - { - Log.Warning("Context.User was null"); - await RespondAsync( - "An unknown error has occurred. Instar developers have been notified.", - ephemeral: true); - return; - } - - if ((int)month is < 0 or > 12) - { - await RespondAsync( - "There are only 12 months in a year. Your birthday was not set.", - ephemeral: true); - return; - } + public async Task SetBirthday( + [MinValue(1)] [MaxValue(12)] [Summary(description: "The month you were born.")] + Month month, + [MinValue(1)] [MaxValue(31)] [Summary(description: "The day you were born.")] + int day, + [MinValue(1900)] [MaxValue(2099)] [Summary(description: "The year you were born. We use this to automatically update age roles.")] + int? year = null, + [MinValue(-12)] + [MaxValue(+12)] + [Summary("timezone", "Set your time zone so your birthday role is applied at the correct time of day.")] + [Autocomplete] + int tzOffset = 0) + { + if (Context.User is null) + { + Log.Warning("Context.User was null"); + await RespondAsync( + Strings.Command_SetBirthday_Error_Unknown, + ephemeral: true); + return; + } - var daysInMonth = DateTime.DaysInMonth(year, (int)month); + if ((int) month is < 0 or > 12) + { + await RespondAsync( + Strings.Command_SetBirthday_MonthsOutOfRange, + ephemeral: true); + return; + } - // First step: Does the provided number of days exceed the number of days in the given month? - if (day > daysInMonth) - { - await RespondAsync( - $"There are only {daysInMonth} days in {month} {year}. Your birthday was not set.", - ephemeral: true); - return; - } + // We have to assume a leap year if the user did not provide a year. + int actualYear = year ?? DefaultYear; - var unspecifiedDate = new DateTime(year, (int)month, day, 0, 0, 0, DateTimeKind.Unspecified); - var dtZ = new DateTimeOffset(unspecifiedDate, TimeSpan.FromHours(tzOffset)); + var daysInMonth = DateTime.DaysInMonth(actualYear, (int)month); - var dtLocal = dtZ.DateTime; - var dtUtc = dtZ.UtcDateTime; + // First step: Does the provided number of days exceed the number of days in the given month? + if (day > daysInMonth) + { + await RespondAsync( + string.Format(Strings.Command_SetBirthday_DaysInMonthOutOfRange, daysInMonth, month, year), + ephemeral: true); + return; + } - // Second step: Is the provided birthday actually in the future? - if (dtUtc > DateTime.UtcNow) - { - await RespondAsync( - "You are not a time traveler. Your birthday was not set.", - ephemeral: true); - return; - } + var unspecifiedDate = new DateTime(actualYear, (int)month, day, 0, 0, 0, DateTimeKind.Unspecified); + var dtZ = new DateTimeOffset(unspecifiedDate, TimeSpan.FromHours(tzOffset)); + + // Second step: Is the provided birthday actually in the future? + if (dtZ.UtcDateTime > timeProvider.GetUtcNow()) + { + await RespondAsync( + Strings.Command_SetBirthday_NotTimeTraveler, + ephemeral: true); + return; + } + + var birthday = new Birthday(dtZ, timeProvider); - // Third step: Is the user below the age of 13? - // Note: We will assume all years are 365.25 days to account for leap year madness. - if (DateTime.UtcNow - dtUtc < TimeSpan.FromDays(365.25 * 13)) - Log.Warning("User {UserID} recorded a birthday that puts their age below 13! {UtcTime}", Context.User!.Id, - dtUtc); - // TODO: Notify staff? + + + // Third step: Is the user below the age of 13? + var cfg = await dynamicConfig.GetConfig(); + bool isUnderage = birthday.Age < cfg.BirthdayConfig.MinimumPermissibleAge; try { var dbUser = await ddbService.GetOrCreateUserAsync(Context.User); - dbUser.Data.Birthday = dtUtc; - await dbUser.UpdateAsync(); + if (dbUser.Data.Birthday is not null) + { + var originalBirthdayTimestamp = dbUser.Data.Birthday.Timestamp; + await RespondAsync(string.Format(Strings.Command_SetBirthday_Error_AlreadySet, originalBirthdayTimestamp), ephemeral: true); + return; + } + + if (isUnderage) + { + Log.Warning("User {UserID} recorded a birthday that puts their age below 13! {UtcTime}", Context.User!.Id, + birthday.Birthdate.UtcDateTime); + + await HandleUnderage(cfg, Context.User, birthday); + } + dbUser.Data.Birthday = birthday; + dbUser.Data.Birthdate = birthday.Key; + // If the user is underage and is a new member and does not already have an auto member hold record, + // we automatically withhold their membership for staff review. + if (isUnderage && dbUser.Data is { Position: InstarUserPosition.NewMember, AutoMemberHoldRecord: null }) + { + dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + Date = timeProvider.GetUtcNow().UtcDateTime, + ModeratorID = cfg.BotUserID, + Reason = string.Format(Strings.Command_SetBirthday_Underage_AMHReason, birthday.Timestamp, cfg.BirthdayConfig.MinimumPermissibleAge) + }; + } + + await dbUser.UpdateAsync(); + Log.Information("User {UserID} birthday set to {DateTime} (UTC time calculated as {UtcTime})", Context.User!.Id, - dtLocal, dtUtc); + birthday.Birthdate, birthday.Birthdate.UtcDateTime); + + // Fourth step: Grant birthday role if the user's birthday is today THEIR time. + // TODO: Ensure that a user is granted/removed birthday roles appropriately after setting their birthday IF their birthday is today. + if (birthday.IsToday) + { + // User's birthday is today in their timezone; grant birthday role. + await birthdaySystem.GrantUnexpectedBirthday(Context.User, birthday); + } - await RespondAsync($"Your birthday was set to {dtLocal:D}.", ephemeral: true); + await RespondAsync(string.Format(Strings.Command_SetBirthday_Success, birthday.Timestamp), ephemeral: true); await metricService.Emit(Metric.BS_BirthdaysSet, 1); } catch (Exception ex) @@ -97,12 +145,21 @@ await RespondAsync( Log.Error(ex, "Failed to update {UserID}'s birthday due to a DynamoDB failure", Context.User!.Id); - await RespondAsync("Your birthday could not be set at this time. Please try again later.", + await RespondAsync(Strings.Command_SetBirthday_Error_CouldNotSetBirthday, ephemeral: true); } } - [UsedImplicitly] + private async Task HandleUnderage(InstarDynamicConfiguration cfg, IGuildUser user, Birthday birthday) + { + var staffAnnounceChannel = Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel); + + var warningEmbed = new InstarUnderageUserWarningEmbed(cfg, user, user.RoleIds.Contains(cfg.MemberRoleID), birthday).Build(); + + await staffAnnounceChannel.SendMessageAsync($"<@&{cfg.StaffRoleID}>", embed: warningEmbed); + } + + [UsedImplicitly] [ExcludeFromCodeCoverage(Justification = "No logic. Just returns list")] [AutocompleteCommand("timezone", "setbirthday")] public async Task HandleTimezoneAutocomplete() @@ -113,7 +170,7 @@ public async Task HandleTimezoneAutocomplete() new("GMT-12 International Date Line West", -12), new("GMT-11 Midway Island, Samoa", -11), new("GMT-10 Hawaii", -10), - new("GMT-9 Alaska", -9), + new("GMT-9 Alaska, R'lyeh", -9), new("GMT-8 Pacific Time (US and Canada); Tijuana", -8), new("GMT-7 Mountain Time (US and Canada)", -7), new("GMT-6 Central Time (US and Canada)", -6), diff --git a/InstarBot/Commands/TriggerBirthdaySystemCommand.cs b/InstarBot/Commands/TriggerBirthdaySystemCommand.cs new file mode 100644 index 0000000..28107f6 --- /dev/null +++ b/InstarBot/Commands/TriggerBirthdaySystemCommand.cs @@ -0,0 +1,21 @@ +using Discord; +using Discord.Interactions; +using JetBrains.Annotations; +using PaxAndromeda.Instar.Services; + +namespace PaxAndromeda.Instar.Commands; + +public class TriggerBirthdaySystemCommand(IBirthdaySystem birthdaySystem) : BaseCommand +{ + [UsedImplicitly] + [RequireOwner] + [DefaultMemberPermissions(GuildPermission.Administrator)] + [SlashCommand("runbirthdays", "Manually triggers an auto member system run.")] + public async Task RunBirthdays() + { + await RespondAsync("Auto Member System is running!", ephemeral: true); + + // Run it asynchronously + await birthdaySystem.RunAsync(); + } +} \ No newline at end of file diff --git a/InstarBot/ConfigModels/InstarDynamicConfiguration.cs b/InstarBot/ConfigModels/InstarDynamicConfiguration.cs index 3631454..62a35a0 100644 --- a/InstarBot/ConfigModels/InstarDynamicConfiguration.cs +++ b/InstarBot/ConfigModels/InstarDynamicConfiguration.cs @@ -8,7 +8,8 @@ namespace PaxAndromeda.Instar.ConfigModels; public sealed class InstarDynamicConfiguration { public string BotName { get; set; } = null!; - [SnowflakeType(SnowflakeType.Guild)] public Snowflake TargetGuild { get; set; } = null!; + [SnowflakeType(SnowflakeType.User)] public Snowflake BotUserID { get; set; } = null!; + [SnowflakeType(SnowflakeType.Guild)] public Snowflake TargetGuild { get; set; } = null!; [SnowflakeType(SnowflakeType.Channel)] public Snowflake TargetChannel { get; set; } = null!; public string Token { get; set; } = null!; public string GaiusAPIKey { get; set; } = null!; @@ -19,10 +20,31 @@ public sealed class InstarDynamicConfiguration [SnowflakeType(SnowflakeType.Role)] public Snowflake MemberRoleID { get; set; } = null!; public Snowflake[] AuthorizedStaffID { get; set; } = null!; public AutoMemberConfig AutoMemberConfig { get; set; } = null!; + public BirthdayConfig BirthdayConfig { get; set; } = null!; public Team[] Teams { get; set; } = null!; public Dictionary FunCommands { get; set; } = null!; } +[UsedImplicitly] +public class BirthdayConfig +{ + [SnowflakeType(SnowflakeType.Role)] + public Snowflake BirthdayRole { get; set; } = null!; + [SnowflakeType(SnowflakeType.Channel)] + public Snowflake BirthdayAnnounceChannel { get; set; } = null!; + public int MinimumPermissibleAge { get; set; } + public List AgeRoleMap { get; set; } = null!; +} + +[UsedImplicitly] +public record AgeRoleMapping +{ + public int Age { get; set; } + + [SnowflakeType(SnowflakeType.Role)] + public Snowflake Role { get; set; } = null!; +} + [UsedImplicitly] public class DynamicAWSConfig { diff --git a/InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs b/InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs index 92942b6..f379538 100644 --- a/InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs +++ b/InstarBot/DynamoModels/ArbitraryDynamoDBTypeConverter.cs @@ -30,15 +30,15 @@ public DynamoDBEntry ToEntry(object value) * to DynamoDBv2 SDK, otherwise we'll just apply this arbitrary type converter. */ - return ToDynamoDBEntry(value); + return ToDynamoDbEntry(value); } - public object FromEntry(DynamoDBEntry entry) + public object? FromEntry(DynamoDBEntry entry) { return FromDynamoDBEntry(entry.AsDocument()); } - private static Document ToDynamoDBEntry(object obj, int maxDepth = 3, int currentDepth = 0) + private static Document ToDynamoDbEntry(object obj, int maxDepth = 3, int currentDepth = 0) { ArgumentNullException.ThrowIfNull(obj); @@ -62,10 +62,10 @@ private static Document ToDynamoDBEntry(object obj, int maxDepth = 3, int curren // Check if a DynamoDBProperty is defined on the property if (Attribute.IsDefined(property, typeof(DynamoDBPropertyAttribute))) { - var dynamoDBProperty = property.GetCustomAttribute(); - if (!string.IsNullOrEmpty(dynamoDBProperty?.AttributeName)) + var dynamoDbProperty = property.GetCustomAttribute(); + if (!string.IsNullOrEmpty(dynamoDbProperty?.AttributeName)) { - propertyName = dynamoDBProperty.AttributeName; + propertyName = dynamoDbProperty.AttributeName; } } @@ -81,56 +81,57 @@ private static Document ToDynamoDBEntry(object obj, int maxDepth = 3, int curren else { // Perform recursive or native handling - doc[propertyName] = ConvertToDynamoDBValue(propertyValue, maxDepth, currentDepth + 1); + doc[propertyName] = ConvertToDynamoDbValue(propertyValue, maxDepth, currentDepth + 1); } } return doc; } - private static DynamoDBEntry ConvertToDynamoDBValue(object value, int maxDepth, int currentDepth) + private static DynamoDBEntry ConvertToDynamoDbValue(object? value, int maxDepth, int currentDepth) { - if (value == null) return new Primitive(); - - // Handle primitive types natively supported by DynamoDB - if (value is string || value is bool || value is int || value is long || value is short || value is double || value is float || value is decimal) - { - return new Primitive - { - Value = value - }; - } - - // Handle DateTime - if (value is DateTime dateTimeVal) - { - return new Primitive - { - Value = dateTimeVal.ToString("o") - }; - } - - // Handle collections (e.g., arrays, lists) - if (value is IEnumerable enumerable) + switch (value) { - var list = new DynamoDBList(); - foreach (var element in enumerable) - { - list.Add(ConvertToDynamoDBValue(element, maxDepth, currentDepth)); - } - return list; + case null: + return new Primitive(); + // Handle primitive types natively supported by DynamoDB + case string: + case bool: + case int: + case long: + case short: + case double: + case float: + case decimal: + return new Primitive + { + Value = value + }; + // Handle DateTime + case DateTime dateTimeVal: + return new Primitive + { + Value = dateTimeVal.ToString("o") + }; + // Handle collections (e.g., arrays, lists) + case IEnumerable enumerable: + { + var list = new DynamoDBList(); + foreach (var element in enumerable) + { + list.Add(ConvertToDynamoDbValue(element, maxDepth, currentDepth)); + } + return list; + } } // Handle objects recursively - if (value.GetType().IsClass) - { - return ToDynamoDBEntry(value, maxDepth, currentDepth); - } - - throw new InvalidOperationException($"Cannot convert type {value.GetType()} to DynamoDBEntry."); + return value.GetType().IsClass + ? ToDynamoDbEntry(value, maxDepth, currentDepth) + : throw new InvalidOperationException($"Cannot convert type {value.GetType()} to DynamoDBEntry."); } - public static T FromDynamoDBEntry(Document document, int maxDepth = 3, int currentDepth = 0) where T : new() + public static TObj FromDynamoDBEntry(Document document, int maxDepth = 3, int currentDepth = 0) where TObj : new() { if (document == null) throw new ArgumentNullException(nameof(document)); @@ -138,7 +139,7 @@ private static DynamoDBEntry ConvertToDynamoDBValue(object value, int maxDepth, if (currentDepth > maxDepth) throw new InvalidOperationException("Max recursion depth reached."); - var obj = new T(); + var obj = new TObj(); foreach (var property in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)) { @@ -160,9 +161,8 @@ private static DynamoDBEntry ConvertToDynamoDBValue(object value, int maxDepth, } // Check if the document contains the property - if (!document.ContainsKey(propertyName)) continue; + if (!document.TryGetValue(propertyName, out DynamoDBEntry? entry)) continue; - var entry = document[propertyName]; var converterAttr = property.GetCustomAttribute()?.Converter; if (converterAttr != null && Activator.CreateInstance(converterAttr) is IPropertyConverter converter) @@ -210,7 +210,8 @@ private static DynamoDBEntry ConvertToDynamoDBValue(object value, int maxDepth, throw new InvalidOperationException($"Cannot determine element type for target type: {targetType}"); var enumerableType = typeof(List<>).MakeGenericType(elementType); - var resultList = (IList)Activator.CreateInstance(enumerableType); + if (Activator.CreateInstance(enumerableType) is not IList resultList) + throw new InvalidOperationException($"Failed to create an IList of target type {targetType}"); foreach (var element in list.Entries) { resultList.Add(FromDynamoDBValue(elementType, element, maxDepth, currentDepth)); diff --git a/InstarBot/DynamoModels/InstarUserData.cs b/InstarBot/DynamoModels/InstarUserData.cs index b5a09a6..f160da6 100644 --- a/InstarBot/DynamoModels/InstarUserData.cs +++ b/InstarBot/DynamoModels/InstarUserData.cs @@ -5,19 +5,32 @@ using Discord; using JetBrains.Annotations; + +// Non-nullable field must contain a non-null value when exiting constructor. +// Since this is a DTO type, we can safely ignore this warning. +#pragma warning disable CS8618 + namespace PaxAndromeda.Instar.DynamoModels; -[DynamoDBTable("TestInstarData")] +[DynamoDBTable("InstarUsers")] [SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] public class InstarUserData { - [DynamoDBHashKey("user_id", Converter = typeof(InstarSnowflakePropertyConverter))] + [DynamoDBHashKey("guild_id", Converter = typeof(InstarSnowflakePropertyConverter))] + [DynamoDBGlobalSecondaryIndexHashKey("birthdate-gsi", AttributeName = "guild_id")] + public Snowflake? GuildID { get; set; } + + [DynamoDBRangeKey("user_id", Converter = typeof(InstarSnowflakePropertyConverter))] public Snowflake? UserID { get; set; } - - [DynamoDBProperty("birthday")] - public DateTime? Birthday { get; set; } - - [DynamoDBProperty("joined")] + + [DynamoDBProperty("birthday", Converter = typeof(InstarBirthdatePropertyConverter))] + public Birthday? Birthday { get; set; } + + [DynamoDBGlobalSecondaryIndexRangeKey("birthdate-gsi", AttributeName = "birthdate")] + [DynamoDBProperty("birthdate")] + public string? Birthdate { get; set; } + + [DynamoDBProperty("joined")] public DateTime? Joined { get; set; } [DynamoDBProperty("position", Converter = typeof(InstarEnumPropertyConverter))] @@ -49,7 +62,9 @@ public string Username get => Usernames?.LastOrDefault()?.Data ?? ""; set { - var time = DateTime.UtcNow; + // We can't pass along a TimeProvider, so we'll + // need to keep DateTime.UtcNow here. + var time = DateTime.UtcNow; if (Usernames is null) { Usernames = [new InstarUserDataHistoricalEntry(time, value)]; @@ -68,6 +83,7 @@ public static InstarUserData CreateFrom(IGuildUser user) { return new InstarUserData { + GuildID = user.GuildId, UserID = user.Id, Birthday = null, Joined = user.JoinedAt?.UtcDateTime, @@ -264,4 +280,26 @@ public object FromEntry(DynamoDBEntry entry) return new Snowflake(id); } +} + +public class InstarBirthdatePropertyConverter : IPropertyConverter +{ + public DynamoDBEntry ToEntry(object value) + { + return value switch + { + DateTimeOffset dto => dto.ToString("o"), + Birthday birthday => birthday.Birthdate.ToString("o"), + _ => throw new InvalidOperationException("Invalid type for Birthdate conversion.") + }; + } + + public object FromEntry(DynamoDBEntry entry) + { + var sEntry = entry.AsString(); + if (sEntry is null || string.IsNullOrWhiteSpace(entry.AsString()) || !DateTimeOffset.TryParse(sEntry, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dto)) + return new DateTimeOffset(); + + return new Birthday(dto, TimeProvider.System); + } } \ No newline at end of file diff --git a/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs b/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs index e584198..0f00f4f 100644 --- a/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs +++ b/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs @@ -12,7 +12,7 @@ protected override EmbedBuilder BuildParts(EmbedBuilder builder) // We only have to focus on the description, author and fields here var fields = new List(); - if (eligibility.HasFlag(MembershipEligibility.NotEligible)) + if (!eligibility.HasFlag(MembershipEligibility.Eligible)) { fields.Add(new EmbedFieldBuilder() .WithName("Missing Items") @@ -43,7 +43,7 @@ private string BuildEligibilityText() eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_RolesEligibility, !eligibility.HasFlag(MembershipEligibility.MissingRoles))); eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_IntroductionEligibility, !eligibility.HasFlag(MembershipEligibility.MissingIntroduction))); - eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_JoinAgeEligibility, !eligibility.HasFlag(MembershipEligibility.TooYoung))); + eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_JoinAgeEligibility, !eligibility.HasFlag(MembershipEligibility.InadequateTenure))); eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_ModActionsEligibility, !eligibility.HasFlag(MembershipEligibility.PunishmentReceived))); eligibilityBuilder.AppendLine(BuildEligibilityComponent(Strings.Command_CheckEligibility_MessagesEligibility, !eligibility.HasFlag(MembershipEligibility.NotEnoughMessages))); @@ -81,7 +81,7 @@ from roleGroup in config.AutoMemberConfig.RequiredRoles if (eligibility.HasFlag(MembershipEligibility.MissingIntroduction)) missingItems.Add(string.Format(Strings.Command_CheckEligibility_MissingItem_Introduction, Snowflake.GetMention(() => config.AutoMemberConfig.IntroductionChannel))); - if (eligibility.HasFlag(MembershipEligibility.TooYoung)) + if (eligibility.HasFlag(MembershipEligibility.InadequateTenure)) missingItems.Add(string.Format(Strings.Command_CheckEligibility_MissingItem_TooYoung, config.AutoMemberConfig.MinimumJoinAge / 3600)); if (eligibility.HasFlag(MembershipEligibility.PunishmentReceived)) diff --git a/InstarBot/Embeds/InstarEligibilityEmbed.cs b/InstarBot/Embeds/InstarEligibilityEmbed.cs index f5dce9c..1493e52 100644 --- a/InstarBot/Embeds/InstarEligibilityEmbed.cs +++ b/InstarBot/Embeds/InstarEligibilityEmbed.cs @@ -54,7 +54,7 @@ private static string BuildEligibilityText(MembershipEligibility eligibility) eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_RolesEligibility, !eligibility.HasFlag(MembershipEligibility.MissingRoles))); eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_IntroductionEligibility, !eligibility.HasFlag(MembershipEligibility.MissingIntroduction))); - eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_JoinAgeEligibility, !eligibility.HasFlag(MembershipEligibility.TooYoung))); + eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_JoinAgeEligibility, !eligibility.HasFlag(MembershipEligibility.InadequateTenure))); eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_ModActionsEligibility, !eligibility.HasFlag(MembershipEligibility.PunishmentReceived))); eligibilityBuilder.AppendLine(BuildEligibilitySnippet(Strings.Command_CheckEligibility_MessagesEligibility, !eligibility.HasFlag(MembershipEligibility.NotEnoughMessages))); diff --git a/InstarBot/Embeds/InstarPageEmbed.cs b/InstarBot/Embeds/InstarPageEmbed.cs index 8f408cb..bdf29a3 100644 --- a/InstarBot/Embeds/InstarPageEmbed.cs +++ b/InstarBot/Embeds/InstarPageEmbed.cs @@ -1,6 +1,5 @@ using Discord; using PaxAndromeda.Instar.ConfigModels; -using System.Threading.Channels; namespace PaxAndromeda.Instar.Embeds; diff --git a/InstarBot/Embeds/InstarReportUserEmbed.cs b/InstarBot/Embeds/InstarReportUserEmbed.cs index 673f4b3..4960074 100644 --- a/InstarBot/Embeds/InstarReportUserEmbed.cs +++ b/InstarBot/Embeds/InstarReportUserEmbed.cs @@ -1,6 +1,5 @@ using Discord; using PaxAndromeda.Instar.Modals; -using System; namespace PaxAndromeda.Instar.Embeds; diff --git a/InstarBot/Embeds/InstarUnderageUserWarningEmbed.cs b/InstarBot/Embeds/InstarUnderageUserWarningEmbed.cs new file mode 100644 index 0000000..896197d --- /dev/null +++ b/InstarBot/Embeds/InstarUnderageUserWarningEmbed.cs @@ -0,0 +1,28 @@ +using Discord; +using PaxAndromeda.Instar.ConfigModels; + +namespace PaxAndromeda.Instar.Embeds; + +public class InstarUnderageUserWarningEmbed(InstarDynamicConfiguration cfg, IGuildUser user, bool isMember, Birthday requestedBirthday) : InstarEmbed +{ + public override Embed Build() + { + int yearsOld = requestedBirthday.Age; + long birthdayTimestamp = requestedBirthday.Timestamp; + + return new EmbedBuilder() + // Set up all the basic stuff first + .WithCurrentTimestamp() + .WithColor(0x0c94e0) + .WithAuthor(cfg.BotName, Strings.InstarLogoUrl) + .WithFooter(new EmbedFooterBuilder() + .WithText(Strings.Embed_BirthdaySystem_Footer) + .WithIconUrl(Strings.InstarLogoUrl)) + .WithDescription(string.Format( + isMember ? Strings.Embed_UnderageUser_WarningTemplate_Member : Strings.Embed_UnderageUser_WarningTemplate_NewMember, + user.Id, + birthdayTimestamp, + yearsOld + )).Build(); + } +} \ No newline at end of file diff --git a/InstarBot/MembershipEligibility.cs b/InstarBot/MembershipEligibility.cs index faf65b3..7a2a29c 100644 --- a/InstarBot/MembershipEligibility.cs +++ b/InstarBot/MembershipEligibility.cs @@ -1,16 +1,53 @@ namespace PaxAndromeda.Instar; +/// +/// A set of flags to describe a user's eligibility for automatically granted membership. +/// [Flags] public enum MembershipEligibility { + /// + /// An invalid state. + /// Invalid = 0x0, + + /// + /// The user is eligible for membership. + /// Eligible = 0x1, - NotEligible = 0x2, - AlreadyMember = 0x4, - TooYoung = 0x8, - MissingRoles = 0x10, - MissingIntroduction = 0x20, - PunishmentReceived = 0x40, - NotEnoughMessages = 0x80, - AutoMemberHold = 0x100 + + /// + /// The user is already a member. + /// + AlreadyMember = 0x2, + + /// + /// The user has not been on the server for long enough. + /// + InadequateTenure = 0x4, + + /// + /// The user is missing required roles. + /// + MissingRoles = 0x8, + + /// + /// The user has not posted an introduction. + /// + MissingIntroduction = 0x10, + + /// + /// The user has received some form of punishment precluding a membership grant. + /// + PunishmentReceived = 0x20, + + /// + /// The user has not sent enough messages on the server. + /// + NotEnoughMessages = 0x40, + + /// + /// The user's membership has been manually withheld. + /// + AutoMemberHold = 0x80 } \ No newline at end of file diff --git a/InstarBot/Metrics/Metric.cs b/InstarBot/Metrics/Metric.cs index 036a2a4..59104d1 100644 --- a/InstarBot/Metrics/Metric.cs +++ b/InstarBot/Metrics/Metric.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using JetBrains.Annotations; namespace PaxAndromeda.Instar.Metrics; @@ -46,6 +45,14 @@ public enum Metric [MetricName("AMH Application Failures")] AMS_AMHFailures, + [MetricDimension("Service", "Birthday System")] + [MetricName("Birthday System Failures")] + BirthdaySystem_Failures, + + [MetricDimension("Service", "Birthday System")] + [MetricName("Birthday Grants")] + BirthdaySystem_Grants, + [MetricDimension("Service", "Discord")] [MetricName("Messages Sent")] Discord_MessagesSent, @@ -60,6 +67,13 @@ public enum Metric [MetricDimension("Service", "Discord")] [MetricName("Users Left")] - [UsedImplicitly] - Discord_UsersLeft + Discord_UsersLeft, + + [MetricDimension("Service", "Gaius")] + [MetricName("Gaius API Calls")] + Gaius_APICalls, + + [MetricDimension("Service", "Gaius")] + [MetricName("Gaius API Latency")] + Gaius_APILatency } \ No newline at end of file diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index b1bfbd7..81240c8 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -23,17 +23,18 @@ public static async Task Main(string[] args) { AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException; - var cli = Parser.Default.ParseArguments(args).Value; #if DEBUG var configPath = "Config/Instar.debug.conf.json"; #else + var cli = Parser.Default.ParseArguments(args).Value; + var configPath = "Config/Instar.conf.json"; if (!string.IsNullOrEmpty(cli.ConfigPath)) configPath = cli.ConfigPath; #endif - - Log.Information("Config path is {Path}", configPath); + + Log.Information("Config path is {Path}", configPath); IConfiguration config = new ConfigurationBuilder() .AddJsonFile(configPath) .Build(); @@ -68,9 +69,17 @@ private static async Task RunAsync(IConfiguration config) var dynamicConfig = _services.GetRequiredService(); await dynamicConfig.Initialize(); - var discordService = _services.GetRequiredService(); + var discordService = _services.GetRequiredService(); await discordService.Start(_services); - } + + // Start up other systems + List tasks = [ + _services.GetRequiredService().Initialize(), + _services.GetRequiredService().Initialize() + ]; + + Task.WaitAll(tasks); + } private static void InitializeLogger(IConfiguration config) { @@ -121,14 +130,21 @@ private static ServiceProvider ConfigureServices(IConfiguration config) // Services services.AddSingleton(); services.AddTransient(); - services.AddTransient(); services.AddTransient(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // Commands & Interactions - services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(TimeProvider.System); + +#if DEBUG + services.AddSingleton(); +#else + services.AddTransient(); +#endif + + // Commands & Interactions + services.AddTransient(); services.AddTransient(); services.AddSingleton(); services.AddTransient(); diff --git a/InstarBot/Services/AWSDynamicConfigService.cs b/InstarBot/Services/AWSDynamicConfigService.cs index c994d8f..e71edaf 100644 --- a/InstarBot/Services/AWSDynamicConfigService.cs +++ b/InstarBot/Services/AWSDynamicConfigService.cs @@ -22,7 +22,8 @@ public interface IDynamicConfigService public sealed class AWSDynamicConfigService : IDynamicConfigService { - private readonly AmazonAppConfigDataClient _appConfigDataClient; + private readonly TimeProvider _timeProvider; + private readonly AmazonAppConfigDataClient _appConfigDataClient; private readonly AmazonSimpleSystemsManagementClient _ssmClient; private string _configData = null!; private string _nextToken = null!; @@ -34,9 +35,10 @@ public sealed class AWSDynamicConfigService : IDynamicConfigService private readonly string _environment; private readonly string _configProfile; - public AWSDynamicConfigService(IConfiguration config) + public AWSDynamicConfigService(IConfiguration config, TimeProvider timeProvider) { - Guard.Against.Null(config); + _timeProvider = timeProvider; + Guard.Against.Null(config); var awsSection = config.GetSection("AWS"); var appConfigSection = awsSection.GetSection("AppConfig"); @@ -64,7 +66,7 @@ public async Task GetConfig() try { await _pollSemaphore.WaitAsync(); - if (DateTime.UtcNow > _nextPollTime) + if (_timeProvider.GetUtcNow().UtcDateTime > _nextPollTime) await Poll(false); return _current; @@ -111,7 +113,7 @@ private async Task Poll(bool bypass) }); _nextToken = result.NextPollConfigurationToken; - _nextPollTime = DateTime.UtcNow + TimeSpan.FromSeconds((double)(result.NextPollIntervalInSeconds ?? 60)); + _nextPollTime = _timeProvider.GetUtcNow().UtcDateTime + TimeSpan.FromSeconds((double)(result.NextPollIntervalInSeconds ?? 60)); // Per the documentation, if VersionLabel is empty, then the client // has the most up-to-date configuration already stored. We can stop diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index c35575c..d2c5d72 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -6,6 +6,7 @@ using PaxAndromeda.Instar.Caching; using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Gaius; using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Modals; using Serilog; @@ -27,6 +28,7 @@ public sealed class AutoMemberSystem : IAutoMemberSystem private readonly IGaiusAPIService _gaiusApiService; private readonly IInstarDDBService _ddbService; private readonly IMetricService _metricService; + private readonly TimeProvider _timeProvider; private Timer _timer = null!; /// @@ -35,27 +37,27 @@ public sealed class AutoMemberSystem : IAutoMemberSystem private Dictionary? _recentMessages; public AutoMemberSystem(IDynamicConfigService dynamicConfig, IDiscordService discord, IGaiusAPIService gaiusApiService, - IInstarDDBService ddbService, IMetricService metricService) + IInstarDDBService ddbService, IMetricService metricService, TimeProvider timeProvider) { _dynamicConfig = dynamicConfig; _discord = discord; _gaiusApiService = gaiusApiService; _ddbService = ddbService; _metricService = metricService; + _timeProvider = timeProvider; discord.UserJoined += HandleUserJoined; + discord.UserLeft += HandleUserLeft; discord.UserUpdated += HandleUserUpdated; discord.MessageReceived += HandleMessageReceived; discord.MessageDeleted += HandleMessageDeleted; - - Task.Run(Initialize).Wait(); } - private async Task Initialize() + public async Task Initialize() { var cfg = await _dynamicConfig.GetConfig(); - _earliestJoinTime = DateTime.UtcNow - TimeSpan.FromSeconds(cfg.AutoMemberConfig.MinimumJoinAge); + _earliestJoinTime = _timeProvider.GetUtcNow().UtcDateTime - TimeSpan.FromSeconds(cfg.AutoMemberConfig.MinimumJoinAge); await PreloadMessageCache(cfg); await PreloadIntroductionPosters(cfg); @@ -66,18 +68,49 @@ private async Task Initialize() StartTimer(); } + /// + /// Filters warnings and caselogs to only focus on the ones we care about. For example, we don't + /// want to withhold membership from someone who was kicked for having a too new Discord account. + /// + /// A collection of warnings retrieved from the Gaius API. + /// A collection of caselogs retrieved from the Gaius API. + /// A tuple containing the filtered warnings and caselogs, respectively. + /// If or is null. + private static (IEnumerable, IEnumerable) FilterPunishments(IEnumerable warnings, IEnumerable caselogs) + { + if (warnings is null) + throw new ArgumentNullException(nameof(warnings)); + if (caselogs is null) + throw new ArgumentNullException(nameof(caselogs)); + + var filteredCaselogs = caselogs.Where(n => n is not { Type: CaselogType.Kick, Reason: "Join age punishment" }); + + return (warnings, filteredCaselogs); + } + private async Task UpdateGaiusPunishments() { // Normally we'd go for 1 hour here, but we can run into // a situation where someone was warned exactly 1.000000001 // hours ago, thus would be missed. To fix this, we'll // bias for an hour and a half ago. - var afterTime = DateTime.UtcNow - TimeSpan.FromHours(1.5); - - foreach (var warning in await _gaiusApiService.GetWarningsAfter(afterTime)) - _punishedUsers.TryAdd(warning.UserID.ID, true); - foreach (var caselog in await _gaiusApiService.GetCaselogsAfter(afterTime)) - _punishedUsers.TryAdd(caselog.UserID.ID, true); + var afterTime = _timeProvider.GetUtcNow().UtcDateTime - TimeSpan.FromHours(1.5); + + try + { + var (warnings, caselogs) = FilterPunishments( + await _gaiusApiService.GetWarningsAfter(afterTime), + await _gaiusApiService.GetCaselogsAfter(afterTime)); + + foreach (var warning in warnings) + _punishedUsers.TryAdd(warning.UserID.ID, true); + + foreach (var caselog in caselogs) + _punishedUsers.TryAdd(caselog.UserID.ID, true); + } catch (Exception ex) + { + Log.Error(ex, "Failed to update Gaius punishments."); + } } private async Task HandleMessageDeleted(Snowflake arg) @@ -148,8 +181,13 @@ private async Task HandleUserJoined(IGuildUser user) } await _metricService.Emit(Metric.Discord_UsersJoined, 1); - } - + } + private async Task HandleUserLeft(IUser arg) + { + // TODO: Maybe handle something here later + await _metricService.Emit(Metric.Discord_UsersLeft, 1); + } + private async Task HandleUserUpdated(UserUpdatedEventArgs arg) { if (!arg.HasUpdated) @@ -183,7 +221,7 @@ private async Task HandleUserUpdated(UserUpdatedEventArgs arg) if (arg.Before.Nickname != arg.After.Nickname) { - user.Data.Nicknames?.Add(new InstarUserDataHistoricalEntry(DateTime.UtcNow, arg.After.Nickname)); + user.Data.Nicknames?.Add(new InstarUserDataHistoricalEntry(_timeProvider.GetUtcNow().UtcDateTime, arg.After.Nickname)); changed = true; } @@ -196,19 +234,22 @@ private async Task HandleUserUpdated(UserUpdatedEventArgs arg) private void StartTimer() { - // Since we can start the bot in the middle of an hour, - // first we must determine the time until the next top - // of hour. - var nextHour = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day, - DateTime.UtcNow.Hour, 0, 0).AddHours(1); - var millisecondsRemaining = (nextHour - DateTime.UtcNow).TotalMilliseconds; + // Since we can start the bot in the middle of an hour, + // first we must determine the time until the next top + // of hour. + var currentTime = _timeProvider.GetUtcNow().UtcDateTime; + var nextHour = new DateTime(currentTime.Year, currentTime.Month, currentTime.Day, + currentTime.Hour, 0, 0).AddHours(1); + var millisecondsRemaining = (nextHour - currentTime).TotalMilliseconds; // Start the timer. In elapsed step, we reset the // duration to exactly 1 hour. _timer = new Timer(millisecondsRemaining); _timer.Elapsed += TimerElapsed; _timer.Start(); - } + + Log.Information("Auto member system timer started, first run in {SecondsRemaining} seconds.", millisecondsRemaining / 1000); + } private async void TimerElapsed(object? sender, ElapsedEventArgs e) { @@ -241,7 +282,7 @@ public async Task RunAsync() await UpdateGaiusPunishments(); } - _earliestJoinTime = DateTime.UtcNow - TimeSpan.FromSeconds(cfg.AutoMemberConfig.MinimumJoinAge); + _earliestJoinTime = _timeProvider.GetUtcNow().UtcDateTime - TimeSpan.FromSeconds(cfg.AutoMemberConfig.MinimumJoinAge); _recentMessages = GetMessagesSent(); Log.Verbose("Earliest join time: {EarliestJoinTime}", _earliestJoinTime); @@ -382,7 +423,7 @@ public MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IG eligibility |= MembershipEligibility.AlreadyMember; if (user.JoinedAt > _earliestJoinTime) - eligibility |= MembershipEligibility.TooYoung; + eligibility |= MembershipEligibility.InadequateTenure; if (!CheckUserRequiredRoles(cfg, user)) eligibility |= MembershipEligibility.MissingRoles; @@ -399,15 +440,12 @@ public MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IG if (user.RoleIds.Contains(cfg.AutoMemberConfig.HoldRole)) eligibility |= MembershipEligibility.AutoMemberHold; + // If the eligibility is no longer exactly Eligible, + // then we can unset that flag. if (eligibility != MembershipEligibility.Eligible) - { - // Unset Eligible flag, add NotEligible flag. - // OPTIMIZE: Do we need the NotEligible flag at all? eligibility &= ~MembershipEligibility.Eligible; - eligibility |= MembershipEligibility.NotEligible; - } - Log.Verbose("User {User} ({UserID}) membership eligibility: {Eligibility}", user.Username, user.Id, eligibility); + Log.Verbose("User {User} ({UserID}) membership eligibility: {Eligibility}", user.Username, user.Id, eligibility); return eligibility; } @@ -442,17 +480,29 @@ private Dictionary GetMessagesSent() private async Task PreloadGaiusPunishments() { - foreach (var warning in await _gaiusApiService.GetAllWarnings()) - _punishedUsers.TryAdd(warning.UserID.ID, true); - foreach (var caselog in await _gaiusApiService.GetAllCaselogs()) - _punishedUsers.TryAdd(caselog.UserID.ID, true); + try + { + var (warnings, caselogs) = FilterPunishments( + await _gaiusApiService.GetAllWarnings(), + await _gaiusApiService.GetAllCaselogs()); + + foreach (var warning in warnings) + _punishedUsers.TryAdd(warning.UserID.ID, true); + foreach (var caselog in caselogs) + _punishedUsers.TryAdd(caselog.UserID.ID, true); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to preload Gaius punishments."); + throw; + } } private async Task PreloadMessageCache(InstarDynamicConfiguration cfg) { Log.Information("Preloading message cache..."); var guild = _discord.GetGuild(); - var earliestMessageTime = DateTime.UtcNow - TimeSpan.FromSeconds(cfg.AutoMemberConfig.MinimumMessageTime); + var earliestMessageTime = _timeProvider.GetUtcNow().UtcDateTime - TimeSpan.FromSeconds(cfg.AutoMemberConfig.MinimumMessageTime); var messages = _discord.GetMessages(guild, earliestMessageTime); await foreach (var message in messages) diff --git a/InstarBot/Services/BirthdaySystem.cs b/InstarBot/Services/BirthdaySystem.cs new file mode 100644 index 0000000..87b37ab --- /dev/null +++ b/InstarBot/Services/BirthdaySystem.cs @@ -0,0 +1,276 @@ +using System.Diagnostics.CodeAnalysis; +using Ardalis.GuardClauses; +using Discord; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using Serilog; +using Metric = PaxAndromeda.Instar.Metrics.Metric; + +namespace PaxAndromeda.Instar.Services; +using System.Timers; + +public sealed class BirthdaySystem ( + IDynamicConfigService dynamicConfig, + IDiscordService discord, + IInstarDDBService ddbService, + IMetricService metricService, + TimeProvider timeProvider) + : IBirthdaySystem +{ + /// + /// The maximum age to be considered 'valid' for age role assignment. + /// + private const int MaximumAge = 150; + + private Timer _timer = null!; + + [ExcludeFromCodeCoverage] + public Task Initialize() + { + StartTimer(); + return Task.CompletedTask; + } + + [ExcludeFromCodeCoverage] + private void StartTimer() + { + // We need to run the birthday check every 30 minutes + // to accomodate any time zone differences. + + var currentTime = timeProvider.GetUtcNow().UtcDateTime; + + bool firstHalfHour = currentTime.Minute < 30; + DateTime currentHour = new (currentTime.Year, currentTime.Month, currentTime.Day, + currentTime.Hour, 0, 0, DateTimeKind.Utc); + + DateTime firstRun = firstHalfHour ? currentHour.AddMinutes(30) : currentHour.AddHours(1); + + var millisecondsRemaining = (firstRun - currentTime).TotalMilliseconds; + + _timer = new Timer(millisecondsRemaining); + _timer.Elapsed += TimerElapsed; + _timer.Start(); + + + + Log.Information("Birthday system timer started, first run in {SecondsRemaining} seconds.", millisecondsRemaining / 1000); + } + + [ExcludeFromCodeCoverage] + private async void TimerElapsed(object? sender, ElapsedEventArgs e) + { + try + { + // Ensure the timer's interval is exactly 30 minutes. + _timer.Interval = 30 * 60 * 1000; + + await RunAsync(); + } + catch + { + // ignore + } + } + + public async Task RunAsync() + { + var cfg = await dynamicConfig.GetConfig(); + var currentTime = timeProvider.GetUtcNow().UtcDateTime; + + await RemoveBirthdays(cfg, currentTime); + var successfulAdds = await GrantBirthdays(cfg, currentTime); + + await metricService.Emit(Metric.BirthdaySystem_Grants, successfulAdds.Count); + + // Now we can create a happy announcement message + if (successfulAdds.Count == 0) + return; + + // Now let's draft up a birthday message + await AnnounceBirthdays(cfg, successfulAdds); + } + + private async Task AnnounceBirthdays(InstarDynamicConfiguration cfg, IEnumerable users) + { + var channel = await discord.GetChannel(cfg.BirthdayConfig.BirthdayAnnounceChannel.ID); + if (channel is not ITextChannel textChannel) + { + Log.Error("Cannot send birthday announcement, channel {ChannelID} not found.", cfg.BirthdayConfig.BirthdayAnnounceChannel.ID); + return; + } + + string mentions = Conjoin(users.Select(s => $"<@{s.ID}>").ToList()); + string message = string.Format(Strings.Birthday_Announcement, cfg.BirthdayConfig.BirthdayRole.ID, mentions); + + await textChannel.SendMessageAsync(message); + } + + private static string Conjoin(IList strings) + { + return strings.Count switch + { + 0 => string.Empty, + 1 => strings[0], + 2 => $"{strings[0]} {Strings.JoiningConjunction} {strings[1]}", + _ => string.Join(", ", strings.Take(strings.Count - 1)) + $", {Strings.JoiningConjunction} {strings.Last()}" + }; + } + + private async Task RemoveBirthdays(InstarDynamicConfiguration cfg, DateTime currentTime) + { + var currentAppliedUsers = discord.GetAllUsersWithRole(cfg.BirthdayConfig.BirthdayRole).Select(n => new Snowflake(n.Id)); + + var batchedUsers = await ddbService.GetBatchUsersAsync(currentAppliedUsers); + + List toRemove = [ ]; + foreach (var user in batchedUsers) + { + if (user.Data.Birthday is null) + { + toRemove.Add(user.Data.UserID!); + continue; + } + + var birthDate = user.Data.Birthday.Birthdate; + + var thisYearBirthday = new DateTime( + currentTime.Year, + birthDate.Month, birthDate.Day, birthDate.Hour, birthDate.Minute, 0, DateTimeKind.Utc); + + if (thisYearBirthday > currentTime) + thisYearBirthday = thisYearBirthday.AddYears(-1); + + + if (currentTime - thisYearBirthday >= TimeSpan.FromDays(1)) + toRemove.Add(user.Data.UserID!); + } + + foreach (var snowflake in toRemove) + { + var guildUser = discord.GetUser(snowflake); + if (guildUser is null) + { + Log.Warning("Cannot grant birthday role to {UserID} as they were not found on the server.", snowflake.ID); + continue; + } + + await guildUser.RemoveRoleAsync(cfg.BirthdayConfig.BirthdayRole); + } + } + + private async Task> GrantBirthdays(InstarDynamicConfiguration cfg, DateTime currentTime) + { + List> dbResults = [ ]; + + await discord.SyncUsers(); + + try + { + // Get all users with birthdays ±15 minutes from the current time. + // BUG: could be off if the timer drifts. maybe we need a metric for expected runtime vs actual runtime? + dbResults.AddRange(await ddbService.GetUsersByBirthday(currentTime, TimeSpan.FromMinutes(10))); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to run birthday routine"); + await metricService.Emit(Metric.BirthdaySystem_Failures, 1); + return [ ]; + } + + // list of user IDs to mention in the happy birthday message + List toMention = [ ]; + + foreach (var result in dbResults) + { + var userId = result.Data.UserID; + if (userId is null) + continue; + + try + { + var guildUser = discord.GetUser(userId); + if (guildUser is null) + { + Log.Warning("Cannot grant birthday role to {UserID} as they were not found on the server.", userId.ID); + continue; + } + + toMention.Add(userId); + + await GrantBirthdayRole(cfg, guildUser, result.Data.Birthday); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to apply birthday role to {UserID}", userId.ID); + } + } + + return toMention; + } + + private static async Task UpdateAgeRole(InstarDynamicConfiguration cfg, IGuildUser user, int ageYears) + { + Guard.Against.Null(cfg); + Guard.Against.Null(user); + + // TODO: update this whenever someone in the world turns 200 years old + Guard.Against.OutOfRange(ageYears, nameof(ageYears), 0, 200); + + // If the user's age is below the youngest age role, there's nothing to do + if (ageYears < cfg.BirthdayConfig.AgeRoleMap.Min(n => n.Age)) + return; + + // Find the appropriate age role to assign. If the current age exceeds + // the maximum age role mapping, we assign the maximum age role. + Snowflake? roleToAssign; + + var maxAgeMap = cfg.BirthdayConfig.AgeRoleMap.MaxBy(n => n.Age); + if (maxAgeMap is null) + throw new BadStateException("Failed to read age role map from dynamic configuration"); + + if (ageYears >= maxAgeMap.Age) + { + roleToAssign = maxAgeMap.Role; + } else + { + // Find the closest age role that does not exceed the user's age + roleToAssign = cfg.BirthdayConfig.AgeRoleMap + .Where(n => n.Age <= ageYears) + .OrderByDescending(n => n.Age) + .Select(n => n.Role) + .FirstOrDefault(); + } + + // Maybe missing a mapping somewhere? + if (roleToAssign is null) + { + Log.Warning("Failed to find appropriate age role for user who is {UserAge} years old.", ageYears); + return; + } + + // We need to identify every age role the user has and remove them first + await user.RemoveRolesAsync(cfg.BirthdayConfig.AgeRoleMap.Select(n => n.Role.ID).Where(user.RoleIds.Contains)); + await user.AddRoleAsync(roleToAssign); + } + + private async Task GrantBirthdayRole(InstarDynamicConfiguration cfg, IGuildUser user, Birthday? birthday) + { + await user.AddRoleAsync(cfg.BirthdayConfig.BirthdayRole); + + if (birthday is null) + return; + + int yearsOld = birthday.Age; + + if (yearsOld < MaximumAge) + await UpdateAgeRole(cfg, user, yearsOld); + } + + public async Task GrantUnexpectedBirthday(IGuildUser user, Birthday birthday) + { + var cfg = await dynamicConfig.GetConfig(); + + await GrantBirthdayRole(cfg, user, birthday); + await AnnounceBirthdays(cfg, [new Snowflake(user.Id)]); + } +} \ No newline at end of file diff --git a/InstarBot/Services/CloudwatchMetricService.cs b/InstarBot/Services/CloudwatchMetricService.cs index 6dc9b70..94d5a64 100644 --- a/InstarBot/Services/CloudwatchMetricService.cs +++ b/InstarBot/Services/CloudwatchMetricService.cs @@ -2,6 +2,7 @@ using Amazon; using Amazon.CloudWatch; using Amazon.CloudWatch.Model; +using Amazon.Runtime; using Microsoft.Extensions.Configuration; using PaxAndromeda.Instar.Metrics; using Serilog; @@ -11,7 +12,11 @@ namespace PaxAndromeda.Instar.Services; public sealed class CloudwatchMetricService : IMetricService { - private readonly AmazonCloudWatchClient _client; + // Exponential backoff parameters + private const int MaxAttempts = 5; + private static readonly TimeSpan BaseDelay = TimeSpan.FromMilliseconds(200); + + private readonly AmazonCloudWatchClient _client; private readonly string _metricNamespace; public CloudwatchMetricService(IConfiguration config) { @@ -23,40 +28,71 @@ public CloudwatchMetricService(IConfiguration config) public async Task Emit(Metric metric, double value) { - try - { - var nameAttr = metric.GetAttributeOfType(); - - var datum = new MetricDatum - { - MetricName = nameAttr is not null ? nameAttr.Name : Enum.GetName(metric), - Value = value, - Dimensions = [] - }; - - var attrs = metric.GetAttributesOfType(); - if (attrs != null) - foreach (var dim in attrs) - { - datum.Dimensions.Add(new Dimension - { - Name = dim.Name, - Value = dim.Value - }); - } - - var response = await _client.PutMetricDataAsync(new PutMetricDataRequest - { - Namespace = _metricNamespace, - MetricData = [datum] - }); - - return response.HttpStatusCode == HttpStatusCode.OK; - } - catch (Exception ex) - { - Log.Error(ex, "Failed to emit metric {Metric} with value {Value}", metric, value); - return false; - } - } + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + var nameAttr = metric.GetAttributeOfType(); + + var datum = new MetricDatum + { + MetricName = nameAttr is not null ? nameAttr.Name : Enum.GetName(metric), + Value = value, + Dimensions = [] + }; + + var attrs = metric.GetAttributesOfType(); + if (attrs != null) + foreach (var dim in attrs) + { + datum.Dimensions.Add(new Dimension + { + Name = dim.Name, + Value = dim.Value + }); + } + + var response = await _client.PutMetricDataAsync(new PutMetricDataRequest + { + Namespace = _metricNamespace, + MetricData = [datum] + }); + + return response.HttpStatusCode == HttpStatusCode.OK; + } catch (Exception ex) when (IsTransient(ex) && attempt < MaxAttempts) + { + var expo = Math.Pow(2, attempt - 1); + var jitter = TimeSpan.FromMilliseconds(new Random().NextDouble() * 100); + var delay = TimeSpan.FromMilliseconds(BaseDelay.TotalMilliseconds * expo) + jitter; + + await Task.Delay(delay); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to emit metric {Metric} with value {Value}", metric, value); + return false; + } + } + + // If we exit the loop without returning, it failed after retries. + Log.Error("Exceeded retry attempts emitting metric {Metric} with value {Value}", metric, value); + return false; + } + + private static bool IsTransient(Exception ex) + { + return ex switch + { + AmazonServiceException ase => + // 5xx errors / throttles are transient + ase.StatusCode == HttpStatusCode.InternalServerError || (int) ase.StatusCode >= 500 || + ase.ErrorCode.Contains("Throttling", StringComparison.OrdinalIgnoreCase) || + ase.ErrorCode.Contains("Throttled", StringComparison.OrdinalIgnoreCase), + // clientside issues + AmazonClientException or WebException => true, + // gracefully handle cancels + OperationCanceledException or TaskCanceledException => false, + _ => ex is TimeoutException + }; + } } \ No newline at end of file diff --git a/InstarBot/Services/DiscordService.cs b/InstarBot/Services/DiscordService.cs index c2fbfe6..0c5c66e 100644 --- a/InstarBot/Services/DiscordService.cs +++ b/InstarBot/Services/DiscordService.cs @@ -24,9 +24,11 @@ public sealed class DiscordService : IDiscordService private readonly IDynamicConfigService _dynamicConfig; private readonly DiscordSocketClient _socketClient; private readonly AsyncEvent _userJoinedEvent = new(); + private readonly AsyncEvent _userLeftEvent = new(); private readonly AsyncEvent _userUpdatedEvent = new(); private readonly AsyncEvent _messageReceivedEvent = new(); private readonly AsyncEvent _messageDeletedEvent = new(); + private readonly AsyncAutoResetEvent _readyEvent = new(false); public event Func UserJoined { @@ -34,6 +36,12 @@ public event Func UserJoined remove => _userJoinedEvent.Remove(value); } + public event Func UserLeft + { + add => _userLeftEvent.Add(value); + remove => _userLeftEvent.Remove(value); + } + public event Func UserUpdated { add => _userUpdatedEvent.Add(value); @@ -78,6 +86,7 @@ public DiscordService(IServiceProvider provider, IConfiguration config, IDynamic _socketClient.MessageReceived += async message => await _messageReceivedEvent.Invoke(message); _socketClient.MessageDeleted += async (msgCache, _) => await _messageDeletedEvent.Invoke(msgCache.Id); _socketClient.UserJoined += async user => await _userJoinedEvent.Invoke(user); + _socketClient.UserLeft += async (_, user) => await _userLeftEvent.Invoke(user); _socketClient.GuildMemberUpdated += HandleUserUpdate; _interactionService.Log += HandleDiscordLog; @@ -90,7 +99,7 @@ public DiscordService(IServiceProvider provider, IConfiguration config, IDynamic throw new ConfigurationException("TargetGuild is not set"); } - private async Task HandleUserUpdate(Cacheable before, SocketGuildUser after) + private async Task HandleUserUpdate(Cacheable before, SocketGuildUser after) { // Can't do anything if we don't have a before state. // In the case of a user join, that is handled in UserJoined event. @@ -163,15 +172,20 @@ public async Task Start(IServiceProvider provider) .Cast().ToArray(); await _socketClient.BulkOverwriteGlobalApplicationCommandsAsync(props); - }; + _readyEvent.Set(); + + }; Log.Verbose("Attempting login..."); await _socketClient.LoginAsync(TokenType.Bot, _botToken); Log.Verbose("Starting Discord..."); await _socketClient.StartAsync(); - } - [SuppressMessage("ReSharper", "TemplateIsNotCompileTimeConstantProblem")] + // Wait until ready + await _readyEvent.WaitAsync(); + } + + [SuppressMessage("ReSharper", "TemplateIsNotCompileTimeConstantProblem")] private static Task HandleDiscordLog(LogMessage arg) { var severity = arg.Severity switch @@ -216,20 +230,50 @@ public async Task> GetAllUsers() return []; } } - - public async IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime) + + public async Task SyncUsers() + { + try + { + var guild = _socketClient.GetGuild(_guild); + await _socketClient.DownloadUsersAsync([guild]); + } catch (Exception ex) + { + Log.Error(ex, "Failed to download users for guild {GuildID}", _guild); + } + } + + public IGuildUser? GetUser(Snowflake snowflake) + { + try + { + var guild = _socketClient.GetGuild(_guild); + + return guild.GetUser(snowflake); + } catch (Exception ex) + { + Log.Error(ex, "Failed to get user {UserID} in guild {GuildID}", snowflake.ID, _guild); + return null; + } + } + + public IEnumerable GetAllUsersWithRole(Snowflake roleId) + { + var guild = _socketClient.GetGuild(_guild); + + return guild.GetRole(roleId).Members; + } + + public async IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime) { Log.Debug("GetMessages({Guild}, {AfterTime})", guild.Id, afterTime); foreach (var channel in guild.TextChannels) { Log.Debug("Downloading #{Channel}", channel.Name); - // Reference message will be the "current" - // message we are looking at. Since the - // GetMessagesAsync() method returns messages - // in order of newest to oldest, we can keep - // a running log of the oldest message we've - // encountered. + // Reference message will be the "current" message we are looking at. Since the + // GetMessagesAsync() method returns messages in order of newest to oldest, we can keep + // a running log of the oldest message we've encountered. var refMessage = (await channel.GetMessagesAsync(1).FlattenAsync()).FirstOrDefault(); if (refMessage is null) continue; diff --git a/InstarBot/Services/FileSystemMetricService.cs b/InstarBot/Services/FileSystemMetricService.cs new file mode 100644 index 0000000..f1d307b --- /dev/null +++ b/InstarBot/Services/FileSystemMetricService.cs @@ -0,0 +1,28 @@ +using System.Reflection; +using PaxAndromeda.Instar.Metrics; + +namespace PaxAndromeda.Instar.Services; + +public class FileSystemMetricService : IMetricService +{ + public FileSystemMetricService() + { + Initialize(); + } + + public void Initialize() + { + // Initialize the metrics subdirectory + // .GetEntryAssembly() can be null in some unmanaged contexts, but that + // doesn't apply here. + var currentDirectory = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location)!; + + Directory.CreateDirectory(Path.Combine(currentDirectory, "metrics")); + } + + public Task Emit(Metric metric, double value) + { + + return Task.FromResult(true); + } +} \ No newline at end of file diff --git a/InstarBot/Services/GaiusAPIService.cs b/InstarBot/Services/GaiusAPIService.cs index a7a0f86..978b18f 100644 --- a/InstarBot/Services/GaiusAPIService.cs +++ b/InstarBot/Services/GaiusAPIService.cs @@ -1,12 +1,12 @@ -using System.Globalization; +using System.Diagnostics; using Newtonsoft.Json; using PaxAndromeda.Instar.Gaius; using System.Text; -using Amazon.Runtime.Internal.Transform; +using PaxAndromeda.Instar.Metrics; namespace PaxAndromeda.Instar.Services; -public sealed class GaiusAPIService(IDynamicConfigService config) : IGaiusAPIService +public sealed class GaiusAPIService(IDynamicConfigService config, IMetricService metrics) : IGaiusAPIService { // Used in release mode // ReSharper disable once NotAccessedField.Local @@ -137,8 +137,14 @@ private static IEnumerable ParseCaselogs(string response) private async Task Get(string url) { var hrm = CreateRequest(url); + await metrics.Emit(Metric.Gaius_APICalls, 1); + + var stopwatch = Stopwatch.StartNew(); var response = await _client.SendAsync(hrm); - + stopwatch.Stop(); + + await metrics.Emit(Metric.Gaius_APILatency, stopwatch.Elapsed.TotalMilliseconds); + return await response.Content.ReadAsStringAsync(); } diff --git a/InstarBot/Services/IAutoMemberSystem.cs b/InstarBot/Services/IAutoMemberSystem.cs index 5490a28..ee221dd 100644 --- a/InstarBot/Services/IAutoMemberSystem.cs +++ b/InstarBot/Services/IAutoMemberSystem.cs @@ -25,4 +25,6 @@ public interface IAutoMemberSystem /// /// MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IGuildUser user); + + Task Initialize(); } \ No newline at end of file diff --git a/InstarBot/Services/IBirthdaySystem.cs b/InstarBot/Services/IBirthdaySystem.cs new file mode 100644 index 0000000..631f9f6 --- /dev/null +++ b/InstarBot/Services/IBirthdaySystem.cs @@ -0,0 +1,18 @@ +using Discord; + +namespace PaxAndromeda.Instar.Services; + +public interface IBirthdaySystem +{ + Task Initialize(); + Task RunAsync(); + + /// + /// Grants the birthday role to a user outside the normal birthday check process. For example, a + /// user sets their birthday to today via command. + /// + /// The user to grant the birthday role to. + /// The user's birthday. + /// Nothing. + Task GrantUnexpectedBirthday(IGuildUser user, Birthday birthday); +} \ No newline at end of file diff --git a/InstarBot/Services/IDiscordService.cs b/InstarBot/Services/IDiscordService.cs index 49d312b..fe6c5f9 100644 --- a/InstarBot/Services/IDiscordService.cs +++ b/InstarBot/Services/IDiscordService.cs @@ -5,7 +5,17 @@ namespace PaxAndromeda.Instar.Services; public interface IDiscordService { - event Func UserJoined; + /// + /// Occurs when a user joins the guild. + /// + event Func UserJoined; + /// + /// Occurs when a user leaves the guild. + /// + event Func UserLeft; + /// + /// Occurs when a user's details are updated, either by the user or otherwise. + /// event Func UserUpdated; event Func MessageReceived; event Func MessageDeleted; @@ -15,4 +25,7 @@ public interface IDiscordService Task> GetAllUsers(); Task GetChannel(Snowflake channelId); IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime); + IGuildUser? GetUser(Snowflake snowflake); + IEnumerable GetAllUsersWithRole(Snowflake roleId); + Task SyncUsers(); } \ No newline at end of file diff --git a/InstarBot/Services/IInstarDDBService.cs b/InstarBot/Services/IInstarDDBService.cs index 40e61af..1bcae19 100644 --- a/InstarBot/Services/IInstarDDBService.cs +++ b/InstarBot/Services/IInstarDDBService.cs @@ -41,4 +41,18 @@ public interface IInstarDDBService /// An instance of to save into DynamoDB. /// Nothing. Task CreateUserAsync(InstarUserData data); + + /// + /// Retrieves a list of users whose birthdays match the specified date, allowing for a margin of error defined by the + /// fuzziness parameter. + /// + /// The search includes users whose birthdays are within the specified fuzziness window before or after + /// the given date. This method performs the comparison in UTC to ensure consistency across time zones. + /// The birthdate to search for. Represents the target date to match against user birthdays. + /// The allowable time range, as a , within which user birthdays are considered a match. Must be + /// non-negative. + /// A task that represents the asynchronous operation. The task result contains a list of objects for users whose birthdays fall within the specified range. + /// Returns an empty list if no users are found. + Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness); } \ No newline at end of file diff --git a/InstarBot/Services/InstarDDBService.cs b/InstarBot/Services/InstarDDBService.cs index 3d8bf79..da3f2b9 100644 --- a/InstarBot/Services/InstarDDBService.cs +++ b/InstarBot/Services/InstarDDBService.cs @@ -1,22 +1,29 @@ -using System.Diagnostics.CodeAnalysis; -using Amazon; +using Amazon; using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; using Discord; using Microsoft.Extensions.Configuration; using PaxAndromeda.Instar.DynamoModels; using Serilog; +using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Services; [ExcludeFromCodeCoverage] public sealed class InstarDDBService : IInstarDDBService { - private readonly DynamoDBContext _ddbContext; + private readonly TimeProvider _timeProvider; + private readonly DynamoDBContext _ddbContext; + private readonly string _guildId; - public InstarDDBService(IConfiguration config) + public InstarDDBService(IConfiguration config, TimeProvider timeProvider) { - var region = config.GetSection("AWS").GetValue("Region"); + _timeProvider = timeProvider; + var region = config.GetSection("AWS").GetValue("Region"); + + _guildId = config.GetValue("TargetGuild") + ?? throw new ConfigurationException("TargetGuild is not set."); var client = new AmazonDynamoDBClient(new AWSIAMCredential(config), RegionEndpoint.GetBySystemName(region)); _ddbContext = new DynamoDBContextBuilder() @@ -30,7 +37,7 @@ public InstarDDBService(IConfiguration config) { try { - var result = await _ddbContext.LoadAsync(snowflake.ID.ToString()); + var result = await _ddbContext.LoadAsync(_guildId, snowflake.ID.ToString()); return result is null ? null : new InstarDatabaseEntry(_ddbContext, result); } catch (Exception ex) @@ -42,7 +49,7 @@ public InstarDDBService(IConfiguration config) public async Task> GetOrCreateUserAsync(IGuildUser user) { - var data = await _ddbContext.LoadAsync(user.Id.ToString()) ?? InstarUserData.CreateFrom(user); + var data = await _ddbContext.LoadAsync(_guildId, user.Id.ToString()) ?? InstarUserData.CreateFrom(user); return new InstarDatabaseEntry(_ddbContext, data); } @@ -51,13 +58,66 @@ public async Task>> GetBatchUsersAsync( { var batches = _ddbContext.CreateBatchGet(); foreach (var snowflake in snowflakes) - batches.AddKey(snowflake.ID.ToString()); + batches.AddKey(_guildId, snowflake.ID.ToString()); await _ddbContext.ExecuteBatchGetAsync(batches); return batches.Results.Select(x => new InstarDatabaseEntry(_ddbContext, x)).ToList(); } + public async Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness) + { + var birthday = new Birthday(birthdate, _timeProvider); + var birthdayUtc = birthday.Birthdate.ToUniversalTime(); + + + var start = birthdayUtc - fuzziness; + var end = birthdayUtc + fuzziness; + + // Build one or two ranges depending on whether we cross midnight + var ranges = new List<(string From, string To)>(); + + if (start.Date == end.Date) + ranges.Add((Utilities.ToBirthdateKey(start), Utilities.ToBirthdateKey(end))); + else + { + // Range 1: start -> end of start day + var endOfStartDay = new DateTime(start.Year, start.Month, start.Day, 23, 59, 59, DateTimeKind.Utc); + ranges.Add((Utilities.ToBirthdateKey(start), Utilities.ToBirthdateKey(endOfStartDay))); + + // Range 2: start of end day -> end + var startOfEndDay = new DateTime(end.Year, end.Month, end.Day, 0, 0, 0, DateTimeKind.Utc); + ranges.Add((Utilities.ToBirthdateKey(startOfEndDay), Utilities.ToBirthdateKey(end))); + } + + var results = new List>(); + + foreach (var range in ranges) + { + var config = new QueryOperationConfig + { + IndexName = "birthdate-gsi", + KeyExpression = new Expression + { + ExpressionStatement = "guild_id = :g AND birthdate BETWEEN :from AND :to", + ExpressionAttributeValues = new Dictionary + { + [":g"] = _guildId, + [":from"] = range.From, + [":to"] = range.To + } + } + }; + + var search = _ddbContext.FromQueryAsync(config); + + var page = await search.GetRemainingAsync().ConfigureAwait(false); + results.AddRange(page.Select(u => new InstarDatabaseEntry(_ddbContext, u))); + } + + return results; + } + public async Task CreateUserAsync(InstarUserData data) { await _ddbContext.SaveAsync(data); diff --git a/InstarBot/Snowflake.cs b/InstarBot/Snowflake.cs index d5be95a..ff4d120 100644 --- a/InstarBot/Snowflake.cs +++ b/InstarBot/Snowflake.cs @@ -14,10 +14,11 @@ namespace PaxAndromeda.Instar; /// /// /// Snowflakes are encoded in the following way: -/// +/// /// Timestamp Wrkr Prcs Increment /// 111111111111111111111111111111111111111111 11111 11111 111111111111 /// 64 22 17 12 0 +/// /// /// Timestamp is the milliseconds since Discord Epoch, the first second of 2015, or 1420070400000 /// Worker ID is the internal worker that generated the ID diff --git a/InstarBot/Strings.Designer.cs b/InstarBot/Strings.Designer.cs index 660fe50..d234ab7 100644 --- a/InstarBot/Strings.Designer.cs +++ b/InstarBot/Strings.Designer.cs @@ -60,6 +60,15 @@ internal Strings() { } } + /// + /// Looks up a localized string similar to :birthday: <@&{0}> {1}!. + /// + public static string Birthday_Announcement { + get { + return ResourceManager.GetString("Birthday_Announcement", resourceCulture); + } + } + /// /// Looks up a localized string similar to Error while attempting to withhold membership for <@{0}>: User is already a member.. /// @@ -456,6 +465,123 @@ public static string Command_ReportUser_ReportSent { } } + /// + /// Looks up a localized string similar to Your birthday has been reset. You may now set your birthday again using `/setbirthday` in the server.. + /// + public static string Command_ResetBirthday_EndUserNotification { + get { + return ResourceManager.GetString("Command_ResetBirthday_EndUserNotification", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Successfully reset the birthday of <@{0}>, but the birthday role could not be automatically removed. The user has been notified of the reset by DM.. + /// + public static string Command_ResetBirthday_Error_RemoveBirthdayRole { + get { + return ResourceManager.GetString("Command_ResetBirthday_Error_RemoveBirthdayRole", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not reset the birthday of <@{0}>: an unknown error has occurred. Please try again later.. + /// + public static string Command_ResetBirthday_Error_Unknown { + get { + return ResourceManager.GetString("Command_ResetBirthday_Error_Unknown", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not reset the birthday of user <@{0}>: user was not found in the database.. + /// + public static string Command_ResetBirthday_Error_UserNotFound { + get { + return ResourceManager.GetString("Command_ResetBirthday_Error_UserNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Successfully reset the birthday of <@{0}>. The user has been notified by DM.. + /// + public static string Command_ResetBirthday_Success { + get { + return ResourceManager.GetString("Command_ResetBirthday_Success", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to There are only {0} days in {1} {2}. Your birthday was not set.. + /// + public static string Command_SetBirthday_DaysInMonthOutOfRange { + get { + return ResourceManager.GetString("Command_SetBirthday_DaysInMonthOutOfRange", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You have already set your birthday on this server to <t:{0}:F>. If this birthday is in error, please contact staff to reset it for you.. + /// + public static string Command_SetBirthday_Error_AlreadySet { + get { + return ResourceManager.GetString("Command_SetBirthday_Error_AlreadySet", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your birthday could not be set at this time. Please try again later.. + /// + public static string Command_SetBirthday_Error_CouldNotSetBirthday { + get { + return ResourceManager.GetString("Command_SetBirthday_Error_CouldNotSetBirthday", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An unknown error has occurred. Instar developers have been notified.. + /// + public static string Command_SetBirthday_Error_Unknown { + get { + return ResourceManager.GetString("Command_SetBirthday_Error_Unknown", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to There are only 12 months in a year. Your birthday was not set.. + /// + public static string Command_SetBirthday_MonthsOutOfRange { + get { + return ResourceManager.GetString("Command_SetBirthday_MonthsOutOfRange", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are not a time traveler. Your birthday was not set.. + /// + public static string Command_SetBirthday_NotTimeTraveler { + get { + return ResourceManager.GetString("Command_SetBirthday_NotTimeTraveler", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your birthday was set to <t:{0}:F>.. + /// + public static string Command_SetBirthday_Success { + get { + return ResourceManager.GetString("Command_SetBirthday_Success", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to User entered the birthday <t:{0}:F> which indicates they are under the age of {1}.. + /// + public static string Command_SetBirthday_Underage_AMHReason { + get { + return ResourceManager.GetString("Command_SetBirthday_Underage_AMHReason", resourceCulture); + } + } + /// /// Looks up a localized string similar to Instar Auto Member System. /// @@ -465,6 +591,15 @@ public static string Embed_AMS_Footer { } } + /// + /// Looks up a localized string similar to Instar Birthday System. + /// + public static string Embed_BirthdaySystem_Footer { + get { + return ResourceManager.GetString("Embed_BirthdaySystem_Footer", resourceCulture); + } + } + /// /// Looks up a localized string similar to Instar Paging System. /// @@ -474,6 +609,26 @@ public static string Embed_Page_Footer { } } + /// + /// Looks up a localized string similar to <@{0}> has set their birthday to <t:{1}:F> which puts their age as {2} years old.. + /// + public static string Embed_UnderageUser_WarningTemplate_Member { + get { + return ResourceManager.GetString("Embed_UnderageUser_WarningTemplate_Member", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to <@{0}> has set their birthday to <t:{1}:F> which puts their age as {2} years old. + /// + ///As the user is a new member, their membership has automatically been withheld pending a staff review.. + /// + public static string Embed_UnderageUser_WarningTemplate_NewMember { + get { + return ResourceManager.GetString("Embed_UnderageUser_WarningTemplate_NewMember", resourceCulture); + } + } + /// /// Looks up a localized string similar to Instar Message Reporting System. /// @@ -491,5 +646,14 @@ public static string InstarLogoUrl { return ResourceManager.GetString("InstarLogoUrl", resourceCulture); } } + + /// + /// Looks up a localized string similar to and. + /// + public static string JoiningConjunction { + get { + return ResourceManager.GetString("JoiningConjunction", resourceCulture); + } + } } } diff --git a/InstarBot/Strings.resx b/InstarBot/Strings.resx index 70110a6..427b42b 100644 --- a/InstarBot/Strings.resx +++ b/InstarBot/Strings.resx @@ -284,4 +284,71 @@ Instar Message Reporting System + + :birthday: <@&{0}> {1}! + {0} is the ID of the Happy Birthday role, {1} is the list of recipients. + + + and + + + Instar Birthday System + + + <@{0}> has set their birthday to <t:{1}:F> which puts their age as {2} years old. + {0} is the user ID, {1} is the user birthday timestamp, and {2} is the user's current age in years. + + + <@{0}> has set their birthday to <t:{1}:F> which puts their age as {2} years old. + +As the user is a new member, their membership has automatically been withheld pending a staff review. + {0} is the user ID, {1} is the user birthday timestamp, and {2} is the user's current age in years. + + + You are not a time traveler. Your birthday was not set. + + + An unknown error has occurred. Instar developers have been notified. + + + There are only {0} days in {1} {2}. Your birthday was not set. + {0} is the number of days in the month {1} of year {2}. + + + There are only 12 months in a year. Your birthday was not set. + + + User entered the birthday <t:{0}:F> which indicates they are under the age of {1}. + {0} is the user birthday timestamp, {1} is the minimum permissible age. + + + Your birthday was set to <t:{0}:F>. + {0} is the user birthday timestamp + + + Your birthday could not be set at this time. Please try again later. + + + You have already set your birthday on this server to <t:{0}:F>. If this birthday is in error, please contact staff to reset it for you. + {0} is the timestamp of the user's birthday. + + + Could not reset the birthday of user <@{0}>: user was not found in the database. + {0] is the user ID. + + + Successfully reset the birthday of <@{0}>. The user has been notified by DM. + {0} is the user ID. + + + Your birthday has been reset. You may now set your birthday again using `/setbirthday` in the server. + + + Could not reset the birthday of <@{0}>: an unknown error has occurred. Please try again later. + {0} is the user ID + + + Successfully reset the birthday of <@{0}>, but the birthday role could not be automatically removed. The user has been notified of the reset by DM. + {0} is the user ID. + \ No newline at end of file diff --git a/InstarBot/Utilities.cs b/InstarBot/Utilities.cs index a1a0eb6..c18747e 100644 --- a/InstarBot/Utilities.cs +++ b/InstarBot/Utilities.cs @@ -1,121 +1,83 @@ -using System.Reflection; +using System.Globalization; +using System.Reflection; using System.Runtime.Serialization; namespace PaxAndromeda.Instar; -public static class EnumExtensions -{ - private static class EnumCache where T : Enum - { - public static readonly IReadOnlyDictionary Map = - typeof(T) - .GetFields(BindingFlags.Public | BindingFlags.Static) - .Select(f => ( - Field: f, - Value: f.GetCustomAttribute()?.Value ?? f.Name, - EnumValue: (T)f.GetValue(null)! - )) - .ToDictionary( - x => x.Value, - x => x.EnumValue, - StringComparer.OrdinalIgnoreCase); - } - - /// - /// Attempts to parse a string representation of an enum value or its associated - /// value to its corresponding enum value. - /// - /// The type of the enum to parse. - /// The string representation of the enum value or its associated - /// value. - /// When this method returns, contains the enum value if parsing succeeded; - /// otherwise, the default value of the enum. - /// - /// true if the string value was successfully parsed to an enum value; - /// otherwise, false. - /// - public static bool TryParseEnumMember(string value, out T result) where T : Enum - { - // NULLABILITY: result can be null if TryGetValue returns false - return EnumCache.Map.TryGetValue(value, out result!); - } - - /// - /// Attempts to parse a string representation of an enum value or its associated - /// value to its corresponding enum value. Throws an exception - /// if the specified value cannot be parsed. - /// - /// The type of the enum to parse. - /// The string representation of the enum value or its associated - /// value. - /// Returns the corresponding enum value of type . - /// Thrown when the specified value does not match any enum value - /// or associated value in the enum type . - public static T ParseEnumMember(string value) where T : Enum - { - return TryParseEnumMember(value, out T result) - ? result - : throw new ArgumentException($"Value '{value}' not found in enum {typeof(T).Name}"); - } - - /// - /// Retrieves the string representation of an enum value as defined by its associated - /// value, or the enum value's name if no attribute is present. - /// - /// The type of the enum. - /// The enum value to retrieve the string representation for. - /// - /// The string representation of the enum value as defined by the , - /// or the enum value's name if no attribute is present. - /// - public static string GetEnumMemberValue(this T value) where T : Enum - { - return EnumCache.Map - .FirstOrDefault(x => EqualityComparer.Default.Equals(x.Value, value)) - .Key ?? value.ToString(); - } -} - public static class Utilities { - /// - /// Retrieves a list of attributes of a specified type defined on an enum value . - /// - /// The type of the attribute to retrieve. - /// The enum value whose attributes are to be retrieved. - /// - /// A list of attributes of the specified type associated with the enum value; - /// or null if no attributes of the specified type are found. - /// - public static List? GetAttributesOfType(this Enum enumVal) where T : Attribute - { - var type = enumVal.GetType(); - var membersInfo = type.GetMember(enumVal.ToString()); - if (membersInfo.Length == 0) - return null; + private static class EnumCache where T : Enum + { + public static readonly IReadOnlyDictionary Map = + typeof(T) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(f => ( + Field: f, + Value: f.GetCustomAttribute()?.Value ?? f.Name, + EnumValue: (T)f.GetValue(null)! + )) + .ToDictionary( + x => x.Value, + x => x.EnumValue, + StringComparer.OrdinalIgnoreCase); + } - var attributes = membersInfo[0].GetCustomAttributes(typeof(T), false); - return attributes.Length > 0 ? attributes.OfType().ToList() : null; - } + extension(Enum enumVal) + { + /// + /// Retrieves a list of attributes of a specified type defined on an enum value . + /// + /// The type of the attribute to retrieve. + /// + /// A list of attributes of the specified type associated with the enum value; + /// or null if no attributes of the specified type are found. + /// + public List? GetAttributesOfType() where T : Attribute + { + var type = enumVal.GetType(); + var membersInfo = type.GetMember(enumVal.ToString()); + if (membersInfo.Length == 0) + return null; - /// - /// Retrieves the first custom attribute of the specified type applied to the - /// member that corresponds to the given enum value . - /// - /// The type of attribute to retrieve. - /// The enum value whose member's custom attribute is retrieved. - /// - /// The first custom attribute of type if found; - /// otherwise, null. - /// - public static T? GetAttributeOfType(this Enum enumVal) where T : Attribute - { - var type = enumVal.GetType(); - var membersInfo = type.GetMember(enumVal.ToString()); - return membersInfo.Length == 0 ? null : membersInfo[0].GetCustomAttribute(false); - } + var attributes = membersInfo[0].GetCustomAttributes(typeof(T), false); + return attributes.Length > 0 ? attributes.OfType().ToList() : null; + } + + /// + /// Retrieves the first custom attribute of the specified type applied to the + /// member that corresponds to the given enum value . + /// + /// The type of attribute to retrieve. + /// + /// The first custom attribute of type if found; + /// otherwise, null. + /// + public T? GetAttributeOfType() where T : Attribute + { + var type = enumVal.GetType(); + var membersInfo = type.GetMember(enumVal.ToString()); + return membersInfo.Length == 0 ? null : membersInfo[0].GetCustomAttribute(false); + } + + /// + /// Retrieves the string representation of an enum value as defined by its associated + /// value, or the enum value's name if no attribute is present. + /// + /// The type of the enum. + /// The enum value to retrieve the string representation for. + /// + /// The string representation of the enum value as defined by the , + /// or the enum value's name if no attribute is present. + /// + public static string GetEnumMemberValue(T value) where T : Enum + { + return EnumCache.Map + .FirstOrDefault(x => EqualityComparer.Default.Equals(x.Value, value)) + .Key ?? value.ToString(); + } + } - /// + /// /// Converts the string representation of an enum value or its associated /// value to its corresponding enum value of type . /// @@ -127,19 +89,67 @@ public static class Utilities /// enum value or associated value in the enum type . public static T ToEnum(string name) where T : Enum { - return EnumExtensions.ParseEnumMember(name); + return ParseEnumMember(name); } - /// - /// Converts a string in SCREAMING_SNAKE_CASE format to PascalCase format. - /// - /// The input string in SCREAMING_SNAKE_CASE format. - /// - /// A string converted from SCREAMING_SNAKE_CASE to PascalCase format. - /// - public static string ScreamingToPascalCase(string input) + /// + /// Attempts to parse a string representation of an enum value or its associated + /// value to its corresponding enum value. + /// + /// The type of the enum to parse. + /// The string representation of the enum value or its associated + /// value. + /// When this method returns, contains the enum value if parsing succeeded; + /// otherwise, the default value of the enum. + /// + /// true if the string value was successfully parsed to an enum value; + /// otherwise, false. + /// + public static bool TryParseEnumMember(string value, out T result) where T : Enum + { + // NULLABILITY: result can be null if TryGetValue returns false + return EnumCache.Map.TryGetValue(value, out result!); + } + + /// + /// Attempts to parse a string representation of an enum value or its associated + /// value to its corresponding enum value. Throws an exception + /// if the specified value cannot be parsed. + /// + /// The type of the enum to parse. + /// The string representation of the enum value or its associated + /// value. + /// Returns the corresponding enum value of type . + /// Thrown when the specified value does not match any enum value + /// or associated value in the enum type . + public static T ParseEnumMember(string value) where T : Enum + { + return TryParseEnumMember(value, out T result) + ? result + : throw new ArgumentException($"Value '{value}' not found in enum {typeof(T).Name}"); + } + + /// + /// Converts a string in SCREAMING_SNAKE_CASE format to PascalCase format. + /// + /// The input string in SCREAMING_SNAKE_CASE format. + /// + /// A string converted from SCREAMING_SNAKE_CASE to PascalCase format. + /// + public static string ScreamingToPascalCase(string input) { // COMMUNITY_MANAGER ⇒ CommunityManager return input.Split('_').Select(piece => piece[0] + piece[1..].ToLower()).Aggregate((a, b) => a + b); } + + /// + /// Generates a string key representing the specified Birthdate in UTC, formatted as month, day, hour, and minute. + /// + /// This method is useful for creating compact, sortable keys based on Birthdate and time. The returned + /// string uses UTC time to ensure consistency across time zones. + /// The Birthdate to convert to a key. The value is interpreted as a local or unspecified time and converted to UTC + /// before formatting. + /// A string containing the UTC month, day, hour, and minute of the Birthdate in the format "MMddHHmm". + public static string ToBirthdateKey(DateTimeOffset dt) => + dt.ToUniversalTime().ToString("MMddHHmm", CultureInfo.InvariantCulture); } \ No newline at end of file From 69dff176fe08f45641b74f98f4a2417d598f6bf6 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Wed, 17 Dec 2025 20:40:32 -0800 Subject: [PATCH 21/53] Introduced new testing framework to make tests more predictable and easier to implement. --- .gitignore | 3 +- .../InstarBot.Tests.Common.csproj | 4 + InstarBot.Tests.Common/MetaTests.cs | 2 +- .../MockInstarDDBServiceTests.cs | 45 --- InstarBot.Tests.Common/Models/TestChannel.cs | 276 ------------- InstarBot.Tests.Common/Models/TestGuild.cs | 31 -- InstarBot.Tests.Common/Models/TestMessage.cs | 152 -------- .../Models/UserDatabaseInformation.cs | 10 - .../Services/MockAutoMemberSystem.cs | 24 -- .../Services/MockDiscordService.cs | 124 ------ .../Services/MockDynamicConfigService.cs | 43 --- .../Services/MockGaiusAPIService.cs | 57 --- .../Services/MockInstarDDBService.cs | 142 ------- InstarBot.Tests.Common/TestContext.cs | 67 ---- InstarBot.Tests.Common/TestUtilities.cs | 345 ----------------- .../InstarBot.Tests.Integration.csproj | 1 + .../AutoMemberSystemCommandTests.cs | 117 +++--- .../CheckEligibilityCommandTests.cs | 215 +++++------ .../Interactions/PageCommandTests.cs | 158 ++++---- .../Interactions/PingCommandTests.cs | 13 +- .../Interactions/ReportUserTests.cs | 118 ++---- .../Interactions/ResetBirthdayCommandTests.cs | 99 +++-- .../Interactions/SetBirthdayCommandTests.cs | 186 ++++----- .../Services/AutoMemberSystemTests.cs | 364 +++++++++--------- .../Services/BirthdaySystemTests.cs | 238 ++++-------- InstarBot.Tests.Integration/TestTest.cs | 49 +++ InstarBot.Tests.Orchestrator/IMockOf.cs | 15 + .../InstarBot.Test.Framework.csproj | 13 + .../MockExtensions.cs | 100 +++++ .../Models/TestChannel.cs | 339 ++++++++++++++++ .../Models/TestGuild.cs | 42 ++ .../Models/TestGuildUser.cs | 146 +++---- .../Models/TestInteractionContext.cs | 71 ++++ .../Models/TestMessage.cs | 155 ++++++++ .../Models/TestRole.cs | 32 +- .../Models/TestSocketUser.cs | 84 ++++ .../Models/TestUser.cs | 25 +- .../Services/TestAutoMemberSystem.cs | 27 ++ .../Services/TestBirthdaySystem.cs | 23 ++ .../Services/TestDatabaseService.cs | 131 +++++++ .../Services/TestDiscordService.cs | 143 +++++++ .../Services/TestDynamicConfigService.cs | 43 +++ .../Services/TestGaiusAPIService.cs | 95 +++++ .../Services/TestMetricService.cs | 10 +- .../TestDatabaseContextBuilder.cs | 41 ++ .../TestDiscordContextBuilder.cs | 134 +++++++ .../TestOrchestrator.cs | 226 +++++++++++ .../TestServiceProviderBuilder.cs | 132 +++++++ .../TestTimeProvider.cs | 28 ++ .../InstarBot.Tests.Unit.csproj | 1 + .../RequireStaffMemberAttributeTests.cs | 51 +-- InstarBot.Tests.Unit/SnowflakeTests.cs | 5 +- InstarBot.sln | 21 +- InstarBot/AppContext.cs | 3 +- InstarBot/Commands/AutoMemberHoldCommand.cs | 6 +- InstarBot/Commands/CheckEligibilityCommand.cs | 4 +- InstarBot/Commands/PageCommand.cs | 4 +- InstarBot/Commands/ResetBirthdayCommand.cs | 4 +- InstarBot/Commands/SetBirthdayCommand.cs | 8 +- InstarBot/DynamoModels/InstarDatabaseEntry.cs | 9 +- InstarBot/DynamoModels/InstarUserData.cs | 4 +- InstarBot/DynamoModels/Notification.cs | 70 ++++ .../Embeds/InstarCheckEligibilityEmbed.cs | 4 +- InstarBot/Embeds/InstarEligibilityEmbed.cs | 6 +- InstarBot/IBuilder.cs | 6 + InstarBot/InstarBot.csproj | 1 + InstarBot/Metrics/Metric.cs | 7 + InstarBot/Program.cs | 18 +- InstarBot/Services/AWSDynamicConfigService.cs | 2 +- InstarBot/Services/AutoMemberSystem.cs | 56 +-- InstarBot/Services/BirthdaySystem.cs | 54 +-- InstarBot/Services/CloudwatchMetricService.cs | 37 +- InstarBot/Services/DiscordService.cs | 4 +- InstarBot/Services/FileSystemMetricService.cs | 7 + InstarBot/Services/GaiusAPIService.cs | 2 +- InstarBot/Services/IAutoMemberSystem.cs | 6 +- InstarBot/Services/IBirthdaySystem.cs | 5 +- ...nstarDDBService.cs => IDatabaseService.cs} | 42 +- InstarBot/Services/IMetricService.cs | 3 +- InstarBot/Services/IRunnableService.cs | 9 + InstarBot/Services/IScheduledService.cs | 5 + InstarBot/Services/IStartableService.cs | 9 + ...DDBService.cs => InstarDynamoDBService.cs} | 55 ++- InstarBot/Services/NotificationService.cs | 50 +++ InstarBot/Services/ScheduledService.cs | 160 ++++++++ InstarBot/Snowflake.cs | 2 +- InstarBot/Utilities.cs | 4 +- 87 files changed, 3235 insertions(+), 2447 deletions(-) delete mode 100644 InstarBot.Tests.Common/MockInstarDDBServiceTests.cs delete mode 100644 InstarBot.Tests.Common/Models/TestChannel.cs delete mode 100644 InstarBot.Tests.Common/Models/TestGuild.cs delete mode 100644 InstarBot.Tests.Common/Models/TestMessage.cs delete mode 100644 InstarBot.Tests.Common/Models/UserDatabaseInformation.cs delete mode 100644 InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs delete mode 100644 InstarBot.Tests.Common/Services/MockDiscordService.cs delete mode 100644 InstarBot.Tests.Common/Services/MockDynamicConfigService.cs delete mode 100644 InstarBot.Tests.Common/Services/MockGaiusAPIService.cs delete mode 100644 InstarBot.Tests.Common/Services/MockInstarDDBService.cs delete mode 100644 InstarBot.Tests.Common/TestContext.cs create mode 100644 InstarBot.Tests.Integration/TestTest.cs create mode 100644 InstarBot.Tests.Orchestrator/IMockOf.cs create mode 100644 InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj create mode 100644 InstarBot.Tests.Orchestrator/MockExtensions.cs create mode 100644 InstarBot.Tests.Orchestrator/Models/TestChannel.cs create mode 100644 InstarBot.Tests.Orchestrator/Models/TestGuild.cs rename {InstarBot.Tests.Common => InstarBot.Tests.Orchestrator}/Models/TestGuildUser.cs (53%) create mode 100644 InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs create mode 100644 InstarBot.Tests.Orchestrator/Models/TestMessage.cs rename {InstarBot.Tests.Common => InstarBot.Tests.Orchestrator}/Models/TestRole.cs (63%) create mode 100644 InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs rename {InstarBot.Tests.Common => InstarBot.Tests.Orchestrator}/Models/TestUser.cs (77%) create mode 100644 InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs create mode 100644 InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs create mode 100644 InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs create mode 100644 InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs create mode 100644 InstarBot.Tests.Orchestrator/Services/TestDynamicConfigService.cs create mode 100644 InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs rename InstarBot.Tests.Common/Services/MockMetricService.cs => InstarBot.Tests.Orchestrator/Services/TestMetricService.cs (67%) create mode 100644 InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs create mode 100644 InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs create mode 100644 InstarBot.Tests.Orchestrator/TestOrchestrator.cs create mode 100644 InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs create mode 100644 InstarBot.Tests.Orchestrator/TestTimeProvider.cs create mode 100644 InstarBot/DynamoModels/Notification.cs create mode 100644 InstarBot/IBuilder.cs rename InstarBot/Services/{IInstarDDBService.cs => IDatabaseService.cs} (65%) create mode 100644 InstarBot/Services/IRunnableService.cs create mode 100644 InstarBot/Services/IScheduledService.cs create mode 100644 InstarBot/Services/IStartableService.cs rename InstarBot/Services/{InstarDDBService.cs => InstarDynamoDBService.cs} (70%) create mode 100644 InstarBot/Services/NotificationService.cs create mode 100644 InstarBot/Services/ScheduledService.cs diff --git a/.gitignore b/.gitignore index b63c471..d4aa06e 100644 --- a/.gitignore +++ b/.gitignore @@ -368,4 +368,5 @@ FodyWeavers.xsd .idea/ # Specflow - Autogenerated files -*.feature.cs \ No newline at end of file +*.feature.cs +/qodana.yaml diff --git a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj index a57be9c..6e8fa45 100644 --- a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj +++ b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj @@ -17,6 +17,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/InstarBot.Tests.Common/MetaTests.cs b/InstarBot.Tests.Common/MetaTests.cs index 6a30743..a032209 100644 --- a/InstarBot.Tests.Common/MetaTests.cs +++ b/InstarBot.Tests.Common/MetaTests.cs @@ -3,7 +3,7 @@ namespace InstarBot.Tests; -public class MetaTests +public static class MetaTests { [Fact] public static void MatchesFormat_WithValidText_ShouldReturnTrue() diff --git a/InstarBot.Tests.Common/MockInstarDDBServiceTests.cs b/InstarBot.Tests.Common/MockInstarDDBServiceTests.cs deleted file mode 100644 index a93b704..0000000 --- a/InstarBot.Tests.Common/MockInstarDDBServiceTests.cs +++ /dev/null @@ -1,45 +0,0 @@ -using FluentAssertions; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.DynamoModels; -using Xunit; - -namespace InstarBot.Tests; - -public class MockInstarDDBServiceTests -{ - [Fact] - public static async Task UpdateMember_ShouldPersist() - { - // Arrange - var mockDDB = new MockInstarDDBService(); - var userId = Snowflake.Generate(); - - var user = new TestGuildUser - { - Id = userId, - Username = "username", - JoinedAt = DateTimeOffset.UtcNow - }; - - await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(user)); - - // Act - var retrievedUserEntry = await mockDDB.GetUserAsync(userId); - retrievedUserEntry.Should().NotBeNull(); - retrievedUserEntry.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord - { - Date = DateTime.UtcNow, - ModeratorID = Snowflake.Generate(), - Reason = "test reason" - }; - await retrievedUserEntry.UpdateAsync(); - - // Assert - var newlyRetrievedUserEntry = await mockDDB.GetUserAsync(userId); - - newlyRetrievedUserEntry.Should().NotBeNull(); - newlyRetrievedUserEntry.Data.AutoMemberHoldRecord.Should().NotBeNull(); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestChannel.cs b/InstarBot.Tests.Common/Models/TestChannel.cs deleted file mode 100644 index 67158ef..0000000 --- a/InstarBot.Tests.Common/Models/TestChannel.cs +++ /dev/null @@ -1,276 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Discord; -using PaxAndromeda.Instar; -using MessageProperties = Discord.MessageProperties; - -#pragma warning disable CS1998 -#pragma warning disable CS8625 - -namespace InstarBot.Tests.Models; - -[SuppressMessage("ReSharper", "ReplaceAutoPropertyWithComputedProperty")] -public sealed class TestChannel(Snowflake id) : ITextChannel -{ - public ulong Id { get; } = id; - public DateTimeOffset CreatedAt { get; } = id.Time; - - public Task ModifyAsync(Action func, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public OverwritePermissions? GetPermissionOverwrite(IRole role) - { - return null; - } - - public OverwritePermissions? GetPermissionOverwrite(IUser user) - { - return null; - } - - public Task RemovePermissionOverwriteAsync(IRole role, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task RemovePermissionOverwriteAsync(IUser user, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task AddPermissionOverwriteAsync(IRole role, OverwritePermissions permissions, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task AddPermissionOverwriteAsync(IUser user, OverwritePermissions permissions, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public IAsyncEnumerable> GetUsersAsync(CacheMode mode = CacheMode.AllowDownload, - RequestOptions options = null) - { - throw new NotImplementedException(); - } - - Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) - { - throw new NotImplementedException(); - } - - public int Position { get; } = 0; - public ChannelFlags Flags { get; } = default!; - public IGuild Guild { get; } = null!; - public ulong GuildId { get; } = 0; - public IReadOnlyCollection PermissionOverwrites { get; } = null!; - - IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) - { - return GetUsersAsync(mode, options); - } - - Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) - { - throw new NotImplementedException(); - } - - public string Name { get; } = null!; - - public Task GetMessageAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, - RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public async IAsyncEnumerable> GetMessagesAsync(int limit = 100, - CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) - { - yield return _messages; - } - - public async IAsyncEnumerable> GetMessagesAsync(ulong fromMessageId, Direction dir, - int limit = 100, CacheMode mode = CacheMode.AllowDownload, - RequestOptions options = null) - { - yield break; - } - - public async IAsyncEnumerable> GetMessagesAsync(IMessage fromMessage, Direction dir, - int limit = 100, CacheMode mode = CacheMode.AllowDownload, - RequestOptions options = null) - { - yield break; - } - - public Task> GetPinnedMessagesAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task DeleteMessageAsync(IMessage message, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task ModifyMessageAsync(ulong messageId, Action func, - RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task TriggerTypingAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public IDisposable EnterTypingState(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public string Mention { get; } = null!; - - public Task DeleteAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task GetCategoryAsync(CacheMode mode = CacheMode.AllowDownload, - RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task SyncPermissionsAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task CreateInviteAsync(int? maxAge, int? maxUses = null, bool isTemporary = false, - bool isUnique = false, - RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge, int? maxUses = null, - bool isTemporary = false, - bool isUnique = false, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge, - int? maxUses = null, - bool isTemporary = false, bool isUnique = false, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = null, - bool isTemporary = false, - bool isUnique = false, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task> GetInvitesAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public ulong? CategoryId { get; } = null; - - public Task CreateWebhookAsync(string name, Stream avatar = null, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task GetWebhookAsync(ulong id, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task> GetWebhooksAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task DeleteMessagesAsync(IEnumerable messages, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task DeleteMessagesAsync(IEnumerable messageIds, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task ModifyAsync(Action func, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, - ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, - IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task> GetActiveThreadsAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public bool IsNsfw { get; } = false; - public string Topic { get; } = null!; - public int SlowModeInterval { get; } = 0; - public ThreadArchiveDuration DefaultArchiveDuration { get; } = default!; - - public int DefaultSlowModeInterval => throw new NotImplementedException(); - - public ChannelType ChannelType => throw new NotImplementedException(); - - private readonly List _messages = []; - - public void AddMessage(IGuildUser user, string message) - { - _messages.Add(new TestMessage(user, message)); - } - - public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) - { - var msg = new TestMessage(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags, poll); - _messages.Add(msg); - - return Task.FromResult(msg as IUserMessage); - } - - public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) - { - throw new NotImplementedException(); - } - - public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) - { - throw new NotImplementedException(); - } - - public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) - { - throw new NotImplementedException(); - } - - public Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestGuild.cs b/InstarBot.Tests.Common/Models/TestGuild.cs deleted file mode 100644 index 6217a51..0000000 --- a/InstarBot.Tests.Common/Models/TestGuild.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Discord; -using PaxAndromeda.Instar; - -namespace InstarBot.Tests.Models; - -// ReSharper disable once ClassWithVirtualMembersNeverInherited.Global -public class TestGuild : IInstarGuild -{ - public ulong Id { get; init; } - public IEnumerable TextChannels { get; init; } = [ ]; - - - public IEnumerable Roles { get; init; } = null!; - - public List Users { get; init; } = []; - - public virtual ITextChannel GetTextChannel(ulong channelId) - { - return TextChannels.First(n => n.Id.Equals(channelId)); - } - - public virtual IRole GetRole(Snowflake roleId) - { - return Roles.First(n => n.Id.Equals(roleId)); - } - - public void AddUser(TestGuildUser user) - { - Users.Add(user); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestMessage.cs b/InstarBot.Tests.Common/Models/TestMessage.cs deleted file mode 100644 index ff675d6..0000000 --- a/InstarBot.Tests.Common/Models/TestMessage.cs +++ /dev/null @@ -1,152 +0,0 @@ -using Discord; -using PaxAndromeda.Instar; -using MessageProperties = Discord.MessageProperties; - -namespace InstarBot.Tests.Models; - -public sealed class TestMessage : IUserMessage, IMessage -{ - - internal TestMessage(IUser user, string message) - { - Id = Snowflake.Generate(); - CreatedAt = DateTimeOffset.Now; - Timestamp = DateTimeOffset.Now; - Author = user; - - Content = message; - } - - public TestMessage(string text, bool isTTS, Embed? embed, RequestOptions? options, AllowedMentions? allowedMentions, MessageReference? messageReference, MessageComponent? components, ISticker[]? stickers, Embed[]? embeds, MessageFlags? flags, PollProperties? poll) - { - Content = text; - IsTTS = isTTS; - Flags= flags; - - var embedList = new List(); - - if (embed is not null) - embedList.Add(embed); - if (embeds is not null) - embedList.AddRange(embeds); - - Flags = flags; - Reference = messageReference; - } - - public ulong Id { get; } - public DateTimeOffset CreatedAt { get; } - - public Task DeleteAsync(RequestOptions options = null!) - { - throw new NotImplementedException(); - } - - public Task AddReactionAsync(IEmote emote, RequestOptions options = null!) - { - throw new NotImplementedException(); - } - - public Task RemoveReactionAsync(IEmote emote, IUser user, RequestOptions options = null!) - { - throw new NotImplementedException(); - } - - public Task RemoveReactionAsync(IEmote emote, ulong userId, RequestOptions options = null!) - { - throw new NotImplementedException(); - } - - public Task RemoveAllReactionsAsync(RequestOptions options = null!) - { - throw new NotImplementedException(); - } - - public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions options = null!) - { - throw new NotImplementedException(); - } - - public IAsyncEnumerable> GetReactionUsersAsync(IEmote emoji, int limit, RequestOptions? options = null, ReactionType type = ReactionType.Normal) - { - throw new NotImplementedException(); - } - - public MessageType Type => default; - public MessageSource Source => default; - public bool IsTTS { get; set; } - - public bool IsPinned => false; - public bool IsSuppressed => false; - public bool MentionedEveryone => false; - public string Content { get; } - public string CleanContent => null!; - public DateTimeOffset Timestamp { get; } - public DateTimeOffset? EditedTimestamp => null; - public IMessageChannel Channel => null!; - public IUser Author { get; } - public IThreadChannel Thread => null!; - public IReadOnlyCollection Attachments => null!; - public IReadOnlyCollection Embeds => null!; - public IReadOnlyCollection Tags => null!; - public IReadOnlyCollection MentionedChannelIds => null!; - public IReadOnlyCollection MentionedRoleIds => null!; - public IReadOnlyCollection MentionedUserIds => null!; - public MessageActivity Activity => null!; - public MessageApplication Application => null!; - public MessageReference Reference { get; set; } - - public IReadOnlyDictionary Reactions => null!; - public IReadOnlyCollection Components => null!; - public IReadOnlyCollection Stickers => null!; - public MessageFlags? Flags { get; set; } - - public IMessageInteraction Interaction => null!; - public MessageRoleSubscriptionData RoleSubscriptionData => null!; - - public PurchaseNotification PurchaseNotification => throw new NotImplementedException(); - - public MessageCallData? CallData => throw new NotImplementedException(); - public Task ModifyAsync(Action func, RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task PinAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task UnpinAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public Task CrosspostAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public string Resolve(TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name, TagHandling roleHandling = TagHandling.Name, - TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name) - { - throw new NotImplementedException(); - } - - public Task EndPollAsync(RequestOptions options) - { - throw new NotImplementedException(); - } - - public IAsyncEnumerable> GetPollAnswerVotersAsync(uint answerId, int? limit = null, ulong? afterId = null, - RequestOptions options = null) - { - throw new NotImplementedException(); - } - - public MessageResolvedData ResolvedData { get; set; } - public IUserMessage ReferencedMessage { get; set; } - public IMessageInteractionMetadata InteractionMetadata { get; set; } - public IReadOnlyCollection ForwardedMessages { get; set; } - public Poll? Poll { get; set; } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/UserDatabaseInformation.cs b/InstarBot.Tests.Common/Models/UserDatabaseInformation.cs deleted file mode 100644 index c0f9baa..0000000 --- a/InstarBot.Tests.Common/Models/UserDatabaseInformation.cs +++ /dev/null @@ -1,10 +0,0 @@ -using PaxAndromeda.Instar; - -namespace InstarBot.Tests.Models; - -public sealed record UserDatabaseInformation(Snowflake Snowflake) -{ - public DateTime Birthday { get; set; } - public DateTime JoinDate { get; set; } - public bool GrantedMembership { get; set; } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs b/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs deleted file mode 100644 index b52aade..0000000 --- a/InstarBot.Tests.Common/Services/MockAutoMemberSystem.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Discord; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.ConfigModels; -using PaxAndromeda.Instar.Services; - -namespace InstarBot.Tests.Services; - -public class MockAutoMemberSystem : IAutoMemberSystem -{ - public Task RunAsync() - { - throw new NotImplementedException(); - } - - public virtual MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IGuildUser user) - { - throw new NotImplementedException(); - } - - public Task Initialize() - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockDiscordService.cs b/InstarBot.Tests.Common/Services/MockDiscordService.cs deleted file mode 100644 index 96ad258..0000000 --- a/InstarBot.Tests.Common/Services/MockDiscordService.cs +++ /dev/null @@ -1,124 +0,0 @@ -using Discord; -using InstarBot.Tests.Models; -using JetBrains.Annotations; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Modals; -using PaxAndromeda.Instar.Services; - -namespace InstarBot.Tests.Services; - -public sealed class MockDiscordService : IDiscordService -{ - private IInstarGuild _guild; - private readonly AsyncEvent _userJoinedEvent = new(); - private readonly AsyncEvent _userLeftEvent = new(); - private readonly AsyncEvent _userUpdatedEvent = new(); - private readonly AsyncEvent _messageReceivedEvent = new(); - private readonly AsyncEvent _messageDeletedEvent = new(); - - public event Func UserJoined - { - add => _userJoinedEvent.Add(value); - remove => _userJoinedEvent.Remove(value); - } - - public event Func UserLeft - { - add => _userLeftEvent.Add(value); - remove => _userLeftEvent.Remove(value); - } - - public event Func UserUpdated - { - add => _userUpdatedEvent.Add(value); - remove => _userUpdatedEvent.Remove(value); - } - - public event Func MessageReceived - { - add => _messageReceivedEvent.Add(value); - remove => _messageReceivedEvent.Remove(value); - } - - public event Func MessageDeleted - { - add => _messageDeletedEvent.Add(value); - remove => _messageDeletedEvent.Remove(value); - } - - internal MockDiscordService(IInstarGuild guild) - { - _guild = guild; - } - - public IInstarGuild Guild - { - get => _guild; - set => _guild = value; - } - - public Task Start(IServiceProvider provider) - { - return Task.CompletedTask; - } - - public IInstarGuild GetGuild() - { - return _guild; - } - - public Task> GetAllUsers() - { - return Task.FromResult(((TestGuild)_guild).Users.AsEnumerable()); - } - - public Task GetChannel(Snowflake channelId) - { - return Task.FromResult(_guild.GetTextChannel(channelId)); - } - - public async IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime) - { - foreach (var channel in guild.TextChannels) - await foreach (var messageList in channel.GetMessagesAsync()) - foreach (var message in messageList) - yield return message; - } - - public IGuildUser? GetUser(Snowflake snowflake) - { - return ((TestGuild) _guild).Users.FirstOrDefault(n => n.Id.Equals(snowflake.ID)); - } - - public IEnumerable GetAllUsersWithRole(Snowflake roleId) - { - return ((TestGuild) _guild).Users.Where(n => n.RoleIds.Contains(roleId.ID)); - } - - public Task SyncUsers() - { - return Task.CompletedTask; - } - - public async Task TriggerUserJoined(IGuildUser user) - { - await _userJoinedEvent.Invoke(user); - } - - public async Task TriggerUserUpdated(UserUpdatedEventArgs args) - { - await _userUpdatedEvent.Invoke(args); - } - - [UsedImplicitly] - public async Task TriggerMessageReceived(IMessage message) - { - await _messageReceivedEvent.Invoke(message); - } - - public void AddUser(TestGuildUser user) - { - var guild = _guild as TestGuild; - guild?.AddUser(user); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockDynamicConfigService.cs b/InstarBot.Tests.Common/Services/MockDynamicConfigService.cs deleted file mode 100644 index 5eaed9c..0000000 --- a/InstarBot.Tests.Common/Services/MockDynamicConfigService.cs +++ /dev/null @@ -1,43 +0,0 @@ -using JetBrains.Annotations; -using Newtonsoft.Json; -using PaxAndromeda.Instar.ConfigModels; -using PaxAndromeda.Instar.Services; - -namespace InstarBot.Tests.Services; - -public sealed class MockDynamicConfigService : IDynamicConfigService -{ - private readonly string _configPath; - private readonly Dictionary _parameters = new(); - private InstarDynamicConfiguration _config = null!; - - public MockDynamicConfigService(string configPath) - { - _configPath = configPath; - - Task.Run(Initialize).Wait(); - } - - [UsedImplicitly] - public MockDynamicConfigService(string configPath, Dictionary parameters) - : this(configPath) - { - _parameters = parameters; - } - - public Task GetConfig() - { - return Task.FromResult(_config); - } - - public Task GetParameter(string parameterName) - { - return Task.FromResult(_parameters[parameterName])!; - } - - public async Task Initialize() - { - var data = await File.ReadAllTextAsync(_configPath); - _config = JsonConvert.DeserializeObject(data)!; - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockGaiusAPIService.cs b/InstarBot.Tests.Common/Services/MockGaiusAPIService.cs deleted file mode 100644 index 6ebc20a..0000000 --- a/InstarBot.Tests.Common/Services/MockGaiusAPIService.cs +++ /dev/null @@ -1,57 +0,0 @@ -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Gaius; -using PaxAndromeda.Instar.Services; - -namespace InstarBot.Tests.Services; - -public sealed class MockGaiusAPIService( - Dictionary> warnings, - Dictionary> caselogs, - bool inhibit = false) - : IGaiusAPIService -{ - public void Dispose() - { - // do nothing - } - - public Task> GetAllWarnings() - { - return Task.FromResult>(warnings.Values.SelectMany(list => list).ToList()); - } - - public Task> GetAllCaselogs() - { - return Task.FromResult>(caselogs.Values.SelectMany(list => list).ToList()); - } - - public Task> GetWarningsAfter(DateTime dt) - { - return Task.FromResult>(from list in warnings.Values from item in list where item.WarnDate > dt select item); - } - - public Task> GetCaselogsAfter(DateTime dt) - { - return Task.FromResult>(from list in caselogs.Values from item in list where item.Date > dt select item); - } - - public Task?> GetWarnings(Snowflake userId) - { - if (inhibit) - return Task.FromResult?>(null); - - return !warnings.TryGetValue(userId, out var warning) - ? Task.FromResult?>([]) - : Task.FromResult?>(warning); - } - - public Task?> GetCaselogs(Snowflake userId) - { - if (inhibit) - return Task.FromResult?>(null); - - return !caselogs.TryGetValue(userId, out var caselog) - ? Task.FromResult?>([]) - : Task.FromResult?>(caselog); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs b/InstarBot.Tests.Common/Services/MockInstarDDBService.cs deleted file mode 100644 index 0aa0446..0000000 --- a/InstarBot.Tests.Common/Services/MockInstarDDBService.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Linq.Expressions; -using Amazon.DynamoDBv2.DataModel; -using Discord; -using Moq; -using Moq.Language.Flow; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.DynamoModels; -using PaxAndromeda.Instar.Services; - -namespace InstarBot.Tests.Services; - -/// -/// A mock implementation of the IInstarDDBService interface for unit testing purposes. -/// This class just uses Moq in the background to provide mockable behavior. -/// -/// -/// Implementation warning: MockInstarDDBService differs from the actual implementation of -/// InstarDDBService. All returned items from are references, -/// meaning any data set on them will persist for future calls. This is different from the -/// concrete implementation, in which you would need to call to -/// persist changes. -/// -public class MockInstarDDBService : IInstarDDBService -{ - private readonly Mock _ddbContextMock = new (); - private readonly Mock _internalMock = new (); - - public MockInstarDDBService() - { - _internalMock.Setup(n => n.GetUserAsync(It.IsAny())) - .ReturnsAsync((InstarDatabaseEntry?) null); - } - - public MockInstarDDBService(IEnumerable preload) - : this() - { - foreach (var data in preload) { - - _internalMock - .Setup(n => n.GetUserAsync(data.UserID!)) - .ReturnsAsync(new InstarDatabaseEntry(_ddbContextMock.Object, data)); - } - } - - public void Register(InstarUserData data) - { - // Quick check to make sure we're not overriding an existing exception - try - { - _internalMock.Object.GetUserAsync(Snowflake.Generate()); - } catch - { - return; - } - - _internalMock - .Setup(n => n.GetUserAsync(data.UserID!)) - .ReturnsAsync(new InstarDatabaseEntry(_ddbContextMock.Object, data)); - } - - public Task?> GetUserAsync(Snowflake snowflake) - { - return _internalMock.Object.GetUserAsync(snowflake); - } - - public async Task> GetOrCreateUserAsync(IGuildUser user) - { - // We can't directly set up this method for mocking due to the custom logic here. - // To work around this, we'll first call the same method on the internal mock. If - // it returns a value, we return that. - var mockedResult = await _internalMock.Object.GetOrCreateUserAsync(user); - - // .GetOrCreateUserAsync is expected to never return null in production. However, - // with mocks, it CAN return null if the method was not set up. - // - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - if (mockedResult is not null) - return mockedResult; - - var result = await _internalMock.Object.GetUserAsync(user.Id); - - if (result is not null) - return result; - - await CreateUserAsync(InstarUserData.CreateFrom(user)); - result = await _internalMock.Object.GetUserAsync(user.Id); - - if (result is null) - Assert.Fail("Failed to correctly set up mocks in MockInstarDDBService"); - - return result; - } - - public async Task>> GetBatchUsersAsync(IEnumerable snowflakes) - { - return await GetLocalUsersAsync(snowflakes).ToListAsync(); - } - - private async IAsyncEnumerable> GetLocalUsersAsync(IEnumerable snowflakes) - { - foreach (var snowflake in snowflakes) - { - var data = await _internalMock.Object.GetUserAsync(snowflake); - - if (data is not null) - yield return data; - } - } - - public Task CreateUserAsync(InstarUserData data) - { - _internalMock - .Setup(n => n.GetUserAsync(data.UserID!)) - .ReturnsAsync(new InstarDatabaseEntry(_ddbContextMock.Object, data)); - - return Task.CompletedTask; - } - - public async Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness) - { - var result = await _internalMock.Object.GetUsersByBirthday(birthdate, fuzziness); - - return result; - } - - /// - /// Configures a setup for the specified expression on the mocked interface, allowing - /// control over the behavior of the mock for the given member. - /// - /// Use this method to define expectations or return values for specific members of when using mocking frameworks. The returned setup object allows chaining of additional - /// configuration methods, such as specifying return values or verifying calls. - /// The type of the value returned by the member specified in the expression. - /// An expression that identifies the member of to set up. Typically, a lambda - /// expression specifying a method or property to mock. - /// An instance that can be used to further configure the behavior of - /// the mock for the specified member. - public ISetup Setup(Expression> expression) - { - return _internalMock.Setup(expression); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/TestContext.cs b/InstarBot.Tests.Common/TestContext.cs deleted file mode 100644 index 515ed1f..0000000 --- a/InstarBot.Tests.Common/TestContext.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Discord; -using InstarBot.Tests.Models; -using Moq; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Gaius; - -namespace InstarBot.Tests; - -public sealed class TestContext -{ - public ulong UserID { get; init; }= 1420070400100; - public const ulong ChannelID = 1420070400200; - public const ulong GuildID = 1420070400300; - - public HashSet UserRoles { get; init; } = []; - - public Action EmbedCallback { get; init; } = _ => { }; - - public Mock TextChannelMock { get; internal set; } = null!; - - public List GuildUsers { get; } = []; - - public Dictionary Channels { get; } = []; - public Dictionary Roles { get; } = []; - - public Dictionary> Warnings { get; } = []; - public Dictionary> Caselogs { get; } = []; - - public Dictionary> UserRolesMap { get; } = []; - - public bool InhibitGaius { get; set; } - public Mock DMChannelMock { get ; set ; } - - public void AddWarning(Snowflake userId, Warning warning) - { - if (!Warnings.TryGetValue(userId, out var list)) - Warnings[userId] = list = []; - list.Add(warning); - } - - public void AddCaselog(Snowflake userId, Caselog caselog) - { - if (!Caselogs.TryGetValue(userId, out var list)) - Caselogs[userId] = list = []; - list.Add(caselog); - } - - public void AddChannel(Snowflake channelId) - { - if (Channels.ContainsKey(channelId)) - throw new InvalidOperationException("Channel already exists."); - - Channels.Add(channelId, new TestChannel(channelId)); - } - - public ITextChannel GetChannel(Snowflake channelId) - { - return Channels[channelId]; - } - - public void AddRoles(IEnumerable roles) - { - foreach (var snowflake in roles) - if (!Roles.ContainsKey(snowflake)) - Roles.Add(snowflake, new TestRole(snowflake)); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Common/TestUtilities.cs b/InstarBot.Tests.Common/TestUtilities.cs index 30d14cc..678ec89 100644 --- a/InstarBot.Tests.Common/TestUtilities.cs +++ b/InstarBot.Tests.Common/TestUtilities.cs @@ -1,18 +1,4 @@ -using System.Linq.Expressions; using System.Text.RegularExpressions; -using Discord; -using Discord.Interactions; -using FluentAssertions; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Moq.Protected; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Commands; -using PaxAndromeda.Instar.ConfigModels; -using PaxAndromeda.Instar.Services; using Serilog; using Serilog.Events; @@ -20,337 +6,6 @@ namespace InstarBot.Tests; public static class TestUtilities { - private static IConfiguration? _config; - private static IDynamicConfigService? _dynamicConfig; - - public static ulong GuildID - { - get - { - var cfg = GetTestConfiguration(); - - return ulong.Parse(cfg.GetValue("TargetGuild") ?? throw new ConfigurationException("Expected TargetGuild to be set")); - } - } - - private static IConfiguration GetTestConfiguration() - { - if (_config is not null) - return _config; - - _config = new ConfigurationBuilder() - .AddJsonFile("Config/Instar.test.bare.conf.json") - .Build(); - - return _config; - } - - public static IDynamicConfigService GetDynamicConfiguration() - { - if (_dynamicConfig is not null) - return _dynamicConfig; - - #if DEBUG - var conf = new MockDynamicConfigService("Config/Instar.dynamic.test.debug.conf.json"); - #else - var conf = new MockDynamicConfigService("Config/Instar.dynamic.test.conf.json"); - #endif - - _dynamicConfig = conf; - return conf; - } - - public static TeamService GetTeamService() - { - return new TeamService(GetDynamicConfiguration()); - } - - public static IServiceProvider GetServices() - { - var sc = new ServiceCollection(); - sc.AddSingleton(GetTestConfiguration()); - sc.AddSingleton(GetTeamService()); - sc.AddSingleton(); - sc.AddSingleton(GetDynamicConfiguration()); - - return sc.BuildServiceProvider(); - } - - /// - /// Verifies that the command responded to the user with the correct . - /// - /// A mockup of the command. - /// The string format to check called messages against. - /// A flag indicating whether the message should be ephemeral. - /// A flag indicating whether partial matches are acceptable. - /// The type of command. Must implement . - public static void VerifyMessage(Mock command, string format, bool ephemeral = false, bool partial = false) - where T : BaseCommand - { - command.Protected().Verify( - "RespondAsync", - Times.Once(), - ItExpr.Is( - n => MatchesFormat(n, format, partial)), // text - ItExpr.IsAny(), // embeds - false, // isTTS - ephemeral, // ephemeral - ItExpr.IsAny(), // allowedMentions - ItExpr.IsAny(), // options - ItExpr.IsAny(), // components - ItExpr.IsAny(), // embed - ItExpr.IsAny(), // pollProperties - ItExpr.IsAny() // messageFlags - ); - } - - /// - /// Verifies that the command responded to the user with an embed that satisfies the specified . - /// - /// The type of command. Must implement . - /// A mockup of the command. - /// An instance to verify against. - /// An optional message format, if present. Defaults to null. - /// An optional flag indicating whether the message is expected to be ephemeral. Defaults to false. - /// An optional flag indicating whether partial matches are acceptable. Defaults to false. - public static void VerifyEmbed(Mock command, EmbedVerifier verifier, string? format = null, bool ephemeral = false, bool partial = false) - where T : BaseCommand - { - var msgRef = format is null - ? ItExpr.IsNull() - : ItExpr.Is(n => MatchesFormat(n, format, partial)); - - command.Protected().Verify( - "RespondAsync", - Times.Once(), - msgRef, // text - ItExpr.IsNull(), // embeds - false, // isTTS - ephemeral, // ephemeral - ItExpr.IsAny(), // allowedMentions - ItExpr.IsNull(), // options - ItExpr.IsNull(), // components - ItExpr.Is(e => verifier.Verify(e)), // embed - ItExpr.IsNull(), // pollProperties - ItExpr.IsAny() // messageFlags - ); - } - - public static void VerifyChannelMessage(Mock channel, string format, bool ephemeral = false, bool partial = false) - where T : class, IMessageChannel - { - channel.Verify(c => c.SendMessageAsync( - It.Is(s => MatchesFormat(s, format, partial)), - false, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - )); - } - - public static void VerifyChannelEmbed(Mock channel, EmbedVerifier verifier, string format, bool ephemeral = false, bool partial = false) - where T : class, ITextChannel - { - channel.Verify(c => c.SendMessageAsync( - It.Is(n => MatchesFormat(n, format, partial)), - false, - It.Is(e => verifier.Verify(e)), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - )); - } - - public static IDiscordService SetupDiscordService(TestContext context = null!) - => new MockDiscordService(SetupGuild(context)); - - public static IGaiusAPIService SetupGaiusAPIService(TestContext context = null!) - => new MockGaiusAPIService(context.Warnings, context.Caselogs, context.InhibitGaius); - - private static TestGuild SetupGuild(TestContext context = null!) - { - var guild = new TestGuild - { - Id = Snowflake.Generate(), - TextChannels = context.Channels.Values, - Roles = context.Roles.Values, - Users = context.GuildUsers - }; - - return guild; - } - - public static Mock SetupCommandMock(Expression> newExpression, TestContext context = null!) - where T : BaseCommand - { - var commandMock = new Mock(newExpression); - ConfigureCommandMock(commandMock, context); - return commandMock; - } - - public static Mock SetupCommandMock(TestContext context = null!) - where T : BaseCommand - { - // Quick check: Do we have a constructor that takes IConfiguration? - var iConfigCtor = typeof(T).GetConstructors() - .Any(n => n.GetParameters().Any(info => info.ParameterType == typeof(IConfiguration))); - - var commandMock = iConfigCtor ? new Mock(GetTestConfiguration()) : new Mock(); - ConfigureCommandMock(commandMock, context); - return commandMock; - } - - private static void ConfigureCommandMock(Mock mock, TestContext? context) - where T : BaseCommand - { - context ??= new TestContext(); - - mock.SetupGet(n => n.Context).Returns(SetupContext(context).Object); - - mock.Protected().Setup("RespondAsync", ItExpr.IsNull(), ItExpr.IsNull(), - It.IsAny(), - It.IsAny(), ItExpr.IsNull(), ItExpr.IsNull(), - ItExpr.IsNull(), - ItExpr.IsNull(), - ItExpr.IsNull(), - ItExpr.IsNull()) - .Returns(Task.CompletedTask); - } - - public static Mock SetupContext(TestContext context) - { - var mock = new Mock(); - - mock.SetupGet(static n => n.User!).Returns(SetupUserMock(context).Object); - mock.SetupGet(static n => n.Channel!).Returns(SetupChannelMock(context).Object); - // Note: The following line must occur after the mocking of GetChannel. - mock.SetupGet(static n => n.Guild).Returns(SetupGuildMock(context).Object); - - return mock; - } - - private static Mock SetupGuildMock(TestContext? context) - { - context.Should().NotBeNull(); - - var guildMock = new Mock(); - guildMock.Setup(n => n.Id).Returns(TestContext.GuildID); - guildMock.Setup(n => n.GetTextChannel(It.IsAny())) - .Returns(context.TextChannelMock.Object); - - return guildMock; - } - - public static Mock SetupUserMock(ulong userId) - where T : class, IUser - { - var userMock = new Mock(); - userMock.Setup(n => n.Id).Returns(userId); - - return userMock; - } - - private static Mock SetupUserMock(TestContext? context) - where T : class, IUser - { - var userMock = SetupUserMock(context!.UserID); - - var dmChannelMock = new Mock(); - - context.DMChannelMock = dmChannelMock; - userMock.Setup(n => n.CreateDMChannelAsync(It.IsAny())) - .ReturnsAsync(dmChannelMock.Object); - - if (typeof(T) != typeof(IGuildUser)) return userMock; - - userMock.As().Setup(n => n.RoleIds).Returns(context.UserRoles.Select(n => n.ID).ToList); - - userMock.As().Setup(n => n.AddRoleAsync(It.IsAny(), It.IsAny())) - .Callback((ulong roleId, RequestOptions _) => - { - context.UserRoles.Add(roleId); - }) - .Returns(Task.CompletedTask); - - userMock.As().Setup(n => n.RemoveRoleAsync(It.IsAny(), It.IsAny())) - .Callback((ulong roleId, RequestOptions _) => - { - context.UserRoles.Remove(roleId); - }).Returns(Task.CompletedTask); - - return userMock; - } - - public static Mock SetupChannelMock(ulong channelId) - where T : class, IChannel - { - var channelMock = new Mock(); - channelMock.Setup(n => n.Id).Returns(channelId); - - return channelMock; - } - - private static Mock SetupChannelMock(TestContext context) - where T : class, IChannel - { - var channelMock = SetupChannelMock(TestContext.ChannelID); - - if (typeof(T) != typeof(ITextChannel)) - return channelMock; - - channelMock.As().Setup(n => n.SendMessageAsync(It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Callback((string _, bool _, Embed embed, RequestOptions _, AllowedMentions _, - MessageReference _, MessageComponent _, ISticker[] _, Embed[] _, - MessageFlags _, PollProperties _) => - { - context.EmbedCallback(embed); - }) - .Returns(Task.FromResult(new Mock().Object)); - - context.TextChannelMock = channelMock.As(); - - return channelMock; - } - - public static async IAsyncEnumerable GetTeams(PageTarget pageTarget) - { - var config = await GetDynamicConfiguration().GetConfig(); - var teamsConfig = config.Teams.ToDictionary(n => n.InternalID, n => n); - - teamsConfig.Should().NotBeNull(); - - var teamRefs = pageTarget.GetAttributesOfType()?.Select(n => n.InternalID) ?? - []; - - foreach (var internalId in teamRefs) - { - if (!teamsConfig.TryGetValue(internalId, out var value)) - throw new KeyNotFoundException("Failed to find team with internal ID " + internalId); - - yield return value; - } - } - public static void SetupLogging() { Log.Logger = new LoggerConfiguration() diff --git a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj index e77e0aa..3021c4c 100644 --- a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj +++ b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj @@ -22,6 +22,7 @@ + diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs index fe04992..ead5e32 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs @@ -1,12 +1,14 @@ using Discord; using FluentAssertions; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; +using InstarBot.Test.Framework.Services; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Metrics; +using PaxAndromeda.Instar.Services; using Xunit; namespace InstarBot.Tests.Integration.Interactions; @@ -16,6 +18,7 @@ public static class AutoMemberSystemCommandTests private const ulong NewMemberRole = 796052052433698817ul; private const ulong MemberRole = 793611808372031499ul; + /* private static async Task Setup(bool setupAMH = false) { TestUtilities.SetupLogging(); @@ -24,7 +27,7 @@ private static async Task Setup(bool setupAMH = false) var userID = Snowflake.Generate(); var modID = Snowflake.Generate(); - var mockDDB = new MockInstarDDBService(); + var mockDDB = new MockDatabaseService(); var mockMetrics = new MockMetricService(); @@ -58,7 +61,7 @@ private static async Task Setup(bool setupAMH = false) ModeratorID = modID, Reason = "test reason" }; - await ddbRecord.UpdateAsync(); + await ddbRecord.CommitAsync(); } var commandMock = TestUtilities.SetupCommandMock( @@ -70,23 +73,25 @@ private static async Task Setup(bool setupAMH = false) return new Context(mockDDB, mockMetrics, user, mod, commandMock); } + */ [Fact] public static async Task HoldMember_WithValidUserAndReason_ShouldCreateRecord() { // Arrange - var ctx = await Setup(); + var orchestrator = TestOrchestrator.Default; + var cmd = orchestrator.GetCommand(); // Act - await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + await cmd.Object.HoldMember(orchestrator.Subject, "Test reason"); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Success, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberHold_Success, ephemeral: true); - var record = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + var record = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); record.Should().NotBeNull(); record.Data.AutoMemberHoldRecord.Should().NotBeNull(); - record.Data.AutoMemberHoldRecord.ModeratorID.ID.Should().Be(ctx.Moderator.Id); + record.Data.AutoMemberHoldRecord.ModeratorID.ID.Should().Be(orchestrator.Actor.Id); record.Data.AutoMemberHoldRecord.Reason.Should().Be("Test reason"); } @@ -94,77 +99,87 @@ public static async Task HoldMember_WithValidUserAndReason_ShouldCreateRecord() public static async Task HoldMember_WithNonGuildUser_ShouldGiveError() { // Arrange - var ctx = await Setup(); + var orchestrator = TestOrchestrator.Default; + var cmd = orchestrator.GetCommand(); // Act - await ctx.Command.Object.HoldMember(new TestUser(ctx.TargetUser), "Test reason"); + await cmd.Object.HoldMember(new TestUser(orchestrator.Subject), "Test reason"); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_NotGuildMember, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberHold_Error_NotGuildMember, ephemeral: true); } [Fact] public static async Task HoldMember_AlreadyAMHed_ShouldGiveError() { // Arrange - var ctx = await Setup(true); + var orchestrator = TestOrchestrator.Default; + await orchestrator.CreateAutoMemberHold(orchestrator.Subject); + var cmd = orchestrator.GetCommand(); // Act - await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + await cmd.Object.HoldMember(orchestrator.Subject, "Test reason"); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_AMHAlreadyExists, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberHold_Error_AMHAlreadyExists, ephemeral: true); } [Fact] public static async Task HoldMember_AlreadyMember_ShouldGiveError() { // Arrange - var ctx = await Setup(); - await ctx.TargetUser.AddRoleAsync(MemberRole); + var orchestrator = TestOrchestrator.Default; + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.MemberRoleID); + var cmd = orchestrator.GetCommand(); // Act - await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + await cmd.Object.HoldMember(orchestrator.Subject, "Test reason"); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_AlreadyMember, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberHold_Error_AlreadyMember, ephemeral: true); } [Fact] public static async Task HoldMember_WithDynamoDBError_ShouldRespondWithError() { // Arrange - var ctx = await Setup(); + var orchestrator = TestOrchestrator.Default; + var cmd = orchestrator.GetCommand(); - // Act - ctx.DDBService - .Setup(n => n.GetOrCreateUserAsync(It.IsAny())) - .ThrowsAsync(new BadStateException()); + if (orchestrator.Database is not IMockOf dbMock) + throw new InvalidOperationException("This test depends on the registered database implementing IMockOf"); + + dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(n => n.Id == orchestrator.Subject.Id))).Throws(); - await ctx.Command.Object.HoldMember(ctx.TargetUser, "Test reason"); + // Act + await cmd.Object.HoldMember(orchestrator.Subject, "Test reason"); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberHold_Error_Unexpected, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberHold_Error_Unexpected, ephemeral: true); - var record = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + var record = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); record.Should().NotBeNull(); record.Data.AutoMemberHoldRecord.Should().BeNull(); - ctx.Metrics.GetMetricValues(Metric.AMS_AMHFailures).Sum().Should().BeGreaterThan(0); + + var metrics = (TestMetricService) orchestrator.GetService(); + metrics.GetMetricValues(Metric.AMS_AMHFailures).Sum().Should().BeGreaterThan(0); } [Fact] public static async Task UnholdMember_WithValidUser_ShouldRemoveAMH() { // Arrange - var ctx = await Setup(true); + var orchestrator = TestOrchestrator.Default; + await orchestrator.CreateAutoMemberHold(orchestrator.Subject); + var cmd = orchestrator.GetCommand(); // Act - await ctx.Command.Object.UnholdMember(ctx.TargetUser); + await cmd.Object.UnholdMember(orchestrator.Subject); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Success, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberUnhold_Success, ephemeral: true); - var afterRecord = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + var afterRecord = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); afterRecord.Should().NotBeNull(); afterRecord.Data.AutoMemberHoldRecord.Should().BeNull(); } @@ -173,54 +188,52 @@ public static async Task UnholdMember_WithValidUser_ShouldRemoveAMH() public static async Task UnholdMember_WithValidUserNoActiveHold_ShouldReturnError() { // Arrange - var ctx = await Setup(); + var orchestrator = TestOrchestrator.Default; + var cmd = orchestrator.GetCommand(); // Act - await ctx.Command.Object.UnholdMember(ctx.TargetUser); + await cmd.Object.UnholdMember(orchestrator.Subject); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Error_NoActiveHold, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberUnhold_Error_NoActiveHold, ephemeral: true); } [Fact] public static async Task UnholdMember_WithNonGuildUser_ShouldReturnError() { // Arrange - var ctx = await Setup(); + var orchestrator = TestOrchestrator.Default; + var cmd = orchestrator.GetCommand(); // Act - await ctx.Command.Object.UnholdMember(new TestUser(ctx.TargetUser)); + await cmd.Object.UnholdMember(new TestUser(orchestrator.Subject)); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Error_NotGuildMember, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberUnhold_Error_NotGuildMember, ephemeral: true); } [Fact] public static async Task UnholdMember_WithDynamoError_ShouldReturnError() { // Arrange - var ctx = await Setup(true); + var orchestrator = TestOrchestrator.Default; + await orchestrator.CreateAutoMemberHold(orchestrator.Subject); + var cmd = orchestrator.GetCommand(); + + if (orchestrator.Database is not IMockOf dbMock) + throw new InvalidOperationException("This test depends on the registered database implementing IMockOf"); - ctx.DDBService - .Setup(n => n.GetOrCreateUserAsync(It.IsAny())) - .ThrowsAsync(new BadStateException()); + dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(n => n.Id == orchestrator.Subject.Id))).Throws(); // Act - await ctx.Command.Object.UnholdMember(ctx.TargetUser); + await cmd.Object.UnholdMember(orchestrator.Subject); // Assert - TestUtilities.VerifyMessage(ctx.Command, Strings.Command_AutoMemberUnhold_Error_Unexpected, ephemeral: true); + cmd.VerifyResponse(Strings.Command_AutoMemberUnhold_Error_Unexpected, ephemeral: true); // Sanity check: if the DDB errors out, the AMH should still be there - var afterRecord = await ctx.DDBService.GetUserAsync(ctx.TargetUser.Id); + var afterRecord = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); afterRecord.Should().NotBeNull(); afterRecord.Data.AutoMemberHoldRecord.Should().NotBeNull(); } - - private record Context( - MockInstarDDBService DDBService, - MockMetricService Metrics, - TestGuildUser TargetUser, - TestGuildUser Moderator, - Mock Command); } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs index 13dc002..14e38b9 100644 --- a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs @@ -1,12 +1,13 @@ using Discord; using FluentAssertions; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Services; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Services; using Xunit; namespace InstarBot.Tests.Integration.Interactions; @@ -16,53 +17,28 @@ public static class CheckEligibilityCommandTests private const ulong MemberRole = 793611808372031499ul; private const ulong NewMemberRole = 796052052433698817ul; - private static async Task> SetupCommandMock(CheckEligibilityCommandTestContext context, Action>? setupMocks = null) + private static async Task SetupOrchestrator(MembershipEligibility eligibility) { - TestUtilities.SetupLogging(); + var orchestrator = TestOrchestrator.Default; - var mockAMS = new Mock(); - mockAMS.Setup(n => n.CheckEligibility(It.IsAny(), It.IsAny())).Returns(context.Eligibility); + TestAutoMemberSystem tams = (TestAutoMemberSystem) orchestrator.GetService(); + tams.Mock.Setup(n => n.CheckEligibility(It.IsAny(), It.IsAny())).Returns(eligibility); - var userId = Snowflake.Generate(); - - var mockDDB = new MockInstarDDBService(); - var user = new TestGuildUser - { - Id = userId, - Username = "username", - JoinedAt = DateTimeOffset.Now, - RoleIds = context.Roles - }; - - await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(user)); - - if (context.IsAMH) + /*if (context.IsAMH) { - var ddbUser = await mockDDB.GetUserAsync(userId); - - ddbUser.Should().NotBeNull(); - ddbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Actor.Id); + dbUser.Should().NotBeNull(); + dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord { Date = DateTime.UtcNow, ModeratorID = Snowflake.Generate(), Reason = "Testing" }; - await ddbUser.UpdateAsync(); - } + await dbUser.CommitAsync(); + }*/ - var commandMock = TestUtilities.SetupCommandMock( - () => new CheckEligibilityCommand(TestUtilities.GetDynamicConfiguration(), mockAMS.Object, mockDDB, new MockMetricService()), - new TestContext - { - UserID = userId, - UserRoles = context.Roles.Select(n => new Snowflake(n)).ToHashSet() - }); - - context.DDB = mockDDB; - context.User = user; - - return commandMock; + return orchestrator; } private static EmbedVerifier.VerifierBuilder CreateVerifier() @@ -77,28 +53,30 @@ private static EmbedVerifier.VerifierBuilder CreateVerifier() public static async Task CheckEligibilityCommand_WithExistingMember_ShouldEmitValidMessage() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, [ MemberRole ], MembershipEligibility.Eligible); - var mock = await SetupCommandMock(ctx); + var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var cmd = orchestrator.GetCommand(); + + await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.MemberRoleID); // Act - await mock.Object.CheckEligibility(); + await cmd.Object.CheckEligibility(); // Assert - TestUtilities.VerifyMessage(mock, Strings.Command_CheckEligibility_Error_AlreadyMember, true); + cmd.VerifyResponse(Strings.Command_CheckEligibility_Error_AlreadyMember, ephemeral: true); } [Fact] public static async Task CheckEligibilityCommand_NoMemberRoles_ShouldEmitValidErrorMessage() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, [], MembershipEligibility.Eligible); - var mock = await SetupCommandMock(ctx); + var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var cmd = orchestrator.GetCommand(); // Act - await mock.Object.CheckEligibility(); + await cmd.Object.CheckEligibility(); // Assert - TestUtilities.VerifyMessage(mock, Strings.Command_CheckEligibility_Error_NoMemberRoles, true); + cmd.VerifyResponse(Strings.Command_CheckEligibility_Error_NoMemberRoles, ephemeral: true); } [Fact] @@ -110,19 +88,16 @@ public static async Task CheckEligibilityCommand_Eligible_ShouldEmitValidMessage .WithDescription(Strings.Command_CheckEligibility_MessagesEligibility) .Build(); - var ctx = new CheckEligibilityCommandTestContext( - false, - [ NewMemberRole ], - MembershipEligibility.Eligible); - + var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var cmd = orchestrator.GetCommand(); - var mock = await SetupCommandMock(ctx); + await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); // Act - await mock.Object.CheckEligibility(); + await cmd.Object.CheckEligibility(); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + cmd.VerifyResponse(verifier, ephemeral: true); } [Fact] @@ -136,20 +111,29 @@ public static async Task CheckEligibilityCommand_WithNewMemberAMH_ShouldEmitVali .WithField(Strings.Command_CheckEligibility_AMH_WhatToDo) .WithField(Strings.Command_CheckEligibility_AMH_ContactStaff) .Build(); + + var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var cmd = orchestrator.GetCommand(); - var ctx = new CheckEligibilityCommandTestContext( - true, - [ NewMemberRole ], - MembershipEligibility.Eligible); + await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); + // Give an AMH + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Actor.Id); + dbUser.Should().NotBeNull(); + dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + Date = DateTime.UtcNow, + ModeratorID = Snowflake.Generate(), + Reason = "Testing" + }; - var mock = await SetupCommandMock(ctx); + await dbUser.CommitAsync(); // Act - await mock.Object.CheckEligibility(); + await cmd.Object.CheckEligibility(); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + cmd.VerifyResponse(verifier, ephemeral: true); } [Fact] @@ -161,20 +145,21 @@ public static async Task CheckEligibilityCommand_DDBError_ShouldEmitValidMessage .WithDescription(Strings.Command_CheckEligibility_MessagesEligibility) .Build(); - var ctx = new CheckEligibilityCommandTestContext( - true, - [ NewMemberRole ], - MembershipEligibility.Eligible); + var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var cmd = orchestrator.GetCommand(); + + await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); - var mock = await SetupCommandMock(ctx); + if (orchestrator.Database is not IMockOf dbMock) + throw new InvalidOperationException("This test depends on the registered database implementing IMockOf"); - ctx.DDB.Setup(n => n.GetUserAsync(It.IsAny())).Throws(); + dbMock.Mock.Setup(n => n.GetUserAsync(It.IsAny())).Throws(); // Act - await mock.Object.CheckEligibility(); + await cmd.Object.CheckEligibility(); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + cmd.VerifyResponse(verifier, ephemeral: true); } [Theory] @@ -190,7 +175,7 @@ public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMes { { MembershipEligibility.MissingRoles, Strings.Command_CheckEligibility_MessagesEligibility }, { MembershipEligibility.MissingIntroduction, Strings.Command_CheckEligibility_IntroductionEligibility }, - { MembershipEligibility.InadequateTenure, Strings.Command_CheckEligibility_JoinAgeEligibility }, + { MembershipEligibility.InadequateTenure, Strings.Command_CheckEligibility_JoinAgeEligibility }, { MembershipEligibility.PunishmentReceived, Strings.Command_CheckEligibility_ModActionsEligibility }, { MembershipEligibility.NotEnoughMessages, Strings.Command_CheckEligibility_MessagesEligibility }, }; @@ -199,7 +184,7 @@ public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMes { { MembershipEligibility.MissingRoles, Strings.Command_CheckEligibility_MissingItem_Role }, { MembershipEligibility.MissingIntroduction, Strings.Command_CheckEligibility_MissingItem_Introduction }, - { MembershipEligibility.InadequateTenure, Strings.Command_CheckEligibility_MissingItem_TooYoung }, + { MembershipEligibility.InadequateTenure, Strings.Command_CheckEligibility_MissingItem_TooYoung }, { MembershipEligibility.PunishmentReceived, Strings.Command_CheckEligibility_MissingItem_PunishmentReceived }, { MembershipEligibility.NotEnoughMessages, Strings.Command_CheckEligibility_MissingItem_Messages }, }; @@ -214,114 +199,118 @@ public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMes .WithField(testFieldMap, true) .Build(); - var ctx = new CheckEligibilityCommandTestContext( - false, - [ NewMemberRole ], - eligibility); + var orchestrator = await SetupOrchestrator(eligibility); + var cmd = orchestrator.GetCommand(); - var mock = await SetupCommandMock(ctx); + await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); // Act - await mock.Object.CheckEligibility(); + await cmd.Object.CheckEligibility(); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + cmd.VerifyResponse(verifier, ephemeral: true); } [Fact] public static async Task CheckOtherEligibility_WithEligibleMember_ShouldEmitValidEmbed() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.Eligible); - var mock = await SetupCommandMock(ctx); - ctx.User.Should().NotBeNull(); + var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var cmd = orchestrator.GetCommand(); + + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); + var verifier = CreateVerifier() .WithDescription(Strings.Command_Eligibility_EligibleText) - .WithAuthorName(ctx.User.Username) + .WithAuthorName(orchestrator.Subject.Username) .Build(); // Act - await mock.Object.CheckOtherEligibility(ctx.User); + await cmd.Object.CheckOtherEligibility(orchestrator.Subject); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + cmd.VerifyResponse(verifier, ephemeral: true); } [Fact] public static async Task CheckOtherEligibility_WithIneligibleMember_ShouldEmitValidEmbed() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.MissingRoles); - var mock = await SetupCommandMock(ctx); + var orchestrator = await SetupOrchestrator(MembershipEligibility.MissingRoles); + var cmd = orchestrator.GetCommand(); - ctx.User.Should().NotBeNull(); + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); var verifier = CreateVerifier() .WithDescription(Strings.Command_Eligibility_IneligibleText) - .WithAuthorName(ctx.User.Username) + .WithAuthorName(orchestrator.Subject.Username) .WithField(Strings.Command_Eligibility_Section_Requirements, Strings.Command_CheckEligibility_RolesEligibility, true) .Build(); // Act - await mock.Object.CheckOtherEligibility(ctx.User); + await cmd.Object.CheckOtherEligibility(orchestrator.Subject); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + cmd.VerifyResponse(verifier, ephemeral: true); } [Fact] public static async Task CheckOtherEligibility_WithAMHedMember_ShouldEmitValidEmbed() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(true, [ NewMemberRole ], MembershipEligibility.MissingRoles); - var mock = await SetupCommandMock(ctx); + var orchestrator = await SetupOrchestrator(MembershipEligibility.MissingRoles); + var cmd = orchestrator.GetCommand(); - ctx.User.Should().NotBeNull(); var verifier = CreateVerifier() .WithDescription(Strings.Command_Eligibility_IneligibleText) - .WithAuthorName(ctx.User.Username) + .WithAuthorName(orchestrator.Subject.Username) .WithField(Strings.Command_Eligibility_Section_Hold, Strings.Command_Eligibility_HoldFormat) .Build(); + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); + + // Give the subject an AMH + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); + dbUser.Should().NotBeNull(); + dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + Date = DateTime.UtcNow, + ModeratorID = Snowflake.Generate(), + Reason = "Testing" + }; + + await dbUser.CommitAsync(); + // Act - await mock.Object.CheckOtherEligibility(ctx.User); + await cmd.Object.CheckOtherEligibility(orchestrator.Subject); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); + cmd.VerifyResponse(verifier, ephemeral: true); } [Fact] public static async Task CheckOtherEligibility_WithDynamoError_ShouldEmitValidEmbed() { // Arrange - var ctx = new CheckEligibilityCommandTestContext(false, [ NewMemberRole ], MembershipEligibility.MissingRoles); - var mock = await SetupCommandMock(ctx); + var orchestrator = await SetupOrchestrator(MembershipEligibility.MissingRoles); + var cmd = orchestrator.GetCommand(); - ctx.DDB.Setup(n => n.GetUserAsync(It.IsAny())) - .Throws(new BadStateException("Bad state")); + if (orchestrator.Database is not IMockOf dbMock) + throw new InvalidOperationException("This test depends on the registered database implementing IMockOf"); + dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(n => n.Id == orchestrator.Subject.Id))).Throws(); - ctx.User.Should().NotBeNull(); var verifier = CreateVerifier() .WithDescription(Strings.Command_Eligibility_IneligibleText) - .WithAuthorName(ctx.User.Username) + .WithAuthorName(orchestrator.Subject.Username) .WithField(Strings.Command_Eligibility_Section_Requirements, Strings.Command_CheckEligibility_RolesEligibility, true) .WithField(Strings.Command_Eligibility_Section_AmbiguousHold, Strings.Command_Eligibility_Error_AmbiguousHold) .Build(); // Act - await mock.Object.CheckOtherEligibility(ctx.User); + await cmd.Object.CheckOtherEligibility(orchestrator.Subject); // Assert - TestUtilities.VerifyEmbed(mock, verifier, ephemeral: true); - } - - private record CheckEligibilityCommandTestContext( - bool IsAMH, - List Roles, - MembershipEligibility Eligibility) - { - internal IGuildUser? User { get; set; } - public MockInstarDDBService DDB { get ; set ; } + cmd.VerifyResponse(verifier, ephemeral: true); } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index 260ae3b..77cba58 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -1,9 +1,11 @@ using Discord; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; +using FluentAssertions; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.ConfigModels; using Xunit; namespace InstarBot.Tests.Integration.Interactions; @@ -12,36 +14,47 @@ public static class PageCommandTests { private const string TestReason = "Test reason for paging"; - private static async Task> SetupCommandMock(PageCommandTestContext context) + private static async Task SetupOrchestrator(PageCommandTestContext context) { - // Treat the Test page target as a regular non-staff user on the server - var userTeam = context.UserTeamID == PageTarget.Test - ? Snowflake.Generate() - : (await TestUtilities.GetTeams(context.UserTeamID).FirstAsync()).ID; - - var commandMock = TestUtilities.SetupCommandMock( - () => new PageCommand(TestUtilities.GetTeamService(), new MockMetricService()), - new TestContext - { - UserRoles = [userTeam] - }); - - return commandMock; + var orchestrator = TestOrchestrator.Default; + + if (context.UserTeamID != PageTarget.Test) + await orchestrator.Actor.AddRoleAsync(GetTeamRole(orchestrator, context.UserTeamID)); + + return orchestrator; } - private static async Task GetTeamLead(PageTarget pageTarget) - { - var dynamicConfig = await TestUtilities.GetDynamicConfiguration().GetConfig(); + public static async IAsyncEnumerable GetTeams(TestOrchestrator orchestrator, PageTarget pageTarget) + { + var teamsConfig = orchestrator.Configuration.Teams.ToDictionary(n => n.InternalID, n => n); - var teamsConfig = - dynamicConfig.Teams.ToDictionary(n => n.InternalID, n => n); + teamsConfig.Should().NotBeNull(); - // Eeeeeeeeeeeeevil - return teamsConfig[pageTarget.GetAttributesOfType()?.First().InternalID ?? "idkjustfail"] - .Teamleader; - } + var teamRefs = pageTarget.GetAttributesOfType()?.Select(n => n.InternalID) ?? + []; + + foreach (var internalId in teamRefs) + { + if (!teamsConfig.TryGetValue(internalId, out var value)) + throw new KeyNotFoundException("Failed to find team with internal ID " + internalId); + + yield return value; + } + } + + private static Snowflake GetTeamRole(TestOrchestrator orchestrator, PageTarget owner) + { + var teamId = owner.GetAttributeOfType()?.InternalID; + + if (teamId is null) + throw new InvalidOperationException($"Failed to find a team for {owner}"); + + var team = orchestrator.Configuration.Teams.FirstOrDefault(n => n.InternalID.Equals(teamId, StringComparison.Ordinal)); - private static async Task VerifyPageEmbedEmitted(Mock command, PageCommandTestContext context) + return team is null ? throw new InvalidOperationException($"Failed to find a team for {owner} (internal ID: {teamId})") : team.ID; + } + + private static async Task VerifyPageEmbedEmitted(TestOrchestrator orchestrator, Mock command, PageCommandTestContext context) { var verifier = EmbedVerifier.Builder() .WithFooterText(Strings.Embed_Page_Footer) @@ -70,43 +83,44 @@ private static async Task VerifyPageEmbedEmitted(Mock command, Page { case PageTarget.All: messageFormat = string.Join(' ', - await TestUtilities.GetTeams(PageTarget.All).Select(n => Snowflake.GetMention(() => n.ID)) + await GetTeams(orchestrator, PageTarget.All).Select(n => Snowflake.GetMention(() => n.ID)) .ToArrayAsync()); break; case PageTarget.Test: messageFormat = Strings.Command_Page_TestPageMessage; break; default: - var team = await TestUtilities.GetTeams(context.PageTarget).FirstAsync(); + var team = await GetTeams(orchestrator, context.PageTarget).FirstAsync(); messageFormat = Snowflake.GetMention(() => team.ID); break; } - TestUtilities.VerifyEmbed(command, verifier.Build(), messageFormat); + command.VerifyResponse(messageFormat, verifier.Build()); } [Fact(DisplayName = "User should be able to page when authorized")] public static async Task PageCommand_Authorized_WhenPagingTeam_ShouldPageCorrectly() { - // Arrange - TestUtilities.SetupLogging(); - var context = new PageCommandTestContext( - PageTarget.Owner, - PageTarget.Moderator, - false - ); + // Arrange + var context = new PageCommandTestContext( + PageTarget.Owner, + PageTarget.Moderator, + false + ); + + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); - var command = await SetupCommandMock(context); // Act await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert - await VerifyPageEmbedEmitted(command, context); + await VerifyPageEmbedEmitted(orchestrator, command, context); } - [Fact(DisplayName = "User should be able to page a team's teamleader")] + [Fact(DisplayName = "User should be able to page a team's teamleader")] public static async Task PageCommand_Authorized_WhenPagingTeamLeader_ShouldPageCorrectly() { // Arrange @@ -116,13 +130,14 @@ public static async Task PageCommand_Authorized_WhenPagingTeamLeader_ShouldPageC true ); - var command = await SetupCommandMock(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); // Act await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert - await VerifyPageEmbedEmitted(command, context); + await VerifyPageEmbedEmitted(orchestrator, command, context); } [Fact(DisplayName = "User should be able to page a with user, channel and message")] @@ -138,13 +153,14 @@ public static async Task PageCommand_Authorized_WhenPagingWithData_ShouldPageCor Message: "" ); - var command = await SetupCommandMock(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); // Act await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, context.Message!, context.TargetUser, context.TargetChannel); // Assert - await VerifyPageEmbedEmitted(command, context); + await VerifyPageEmbedEmitted(orchestrator, command, context); } [Fact(DisplayName = "Any staff member should be able to use the Test page")] @@ -161,14 +177,15 @@ public static async Task PageCommand_AnyStaffMember_WhenPagingTest_ShouldPageCor false ); - var command = await SetupCommandMock(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); - // Act - await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, + // Act + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert - await VerifyPageEmbedEmitted(command, context); + await VerifyPageEmbedEmitted(orchestrator, command, context); } } @@ -182,13 +199,14 @@ public static async Task PageCommand_Authorized_WhenOwnerPagingAll_ShouldPageCor false ); - var command = await SetupCommandMock(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); - // Act - await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); + // Act + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); // Assert - await VerifyPageEmbedEmitted(command, context); + await VerifyPageEmbedEmitted(orchestrator, command, context); } [Fact(DisplayName = "Fail page if paging all teamleader")] @@ -201,15 +219,15 @@ public static async Task PageCommand_Authorized_WhenOwnerPagingAllTeamLead_Shoul true ); - var command = await SetupCommandMock(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); - // Act - await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); + // Act + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); - // Assert - TestUtilities.VerifyMessage( - command, - Strings.Command_Page_Error_NoAllTeamlead, + // Assert + command.VerifyResponse( + Strings.Command_Page_Error_NoAllTeamlead, true ); } @@ -224,14 +242,14 @@ public static async Task PageCommand_Unauthorized_WhenAttemptingToPage_ShouldFai false ); - var command = await SetupCommandMock(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); - // Act - await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); + // Act + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); - // Assert - TestUtilities.VerifyMessage( - command, + // Assert + command.VerifyResponse( Strings.Command_Page_Error_NotAuthorized, true ); @@ -247,14 +265,14 @@ public static async Task PageCommand_Authorized_WhenHelperAttemptsToPageAll_Shou false ); - var command = await SetupCommandMock(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); - // Act - await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); + // Act + await command.Object.Page(context.PageTarget, TestReason, context.PagingTeamLeader, string.Empty); - // Assert - TestUtilities.VerifyMessage( - command, + // Assert + command.VerifyResponse( Strings.Command_Page_Error_FullTeamNotAuthorized, true ); diff --git a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs index 7885fc3..de207af 100644 --- a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs @@ -1,4 +1,5 @@ -using PaxAndromeda.Instar.Commands; +using InstarBot.Test.Framework; +using PaxAndromeda.Instar.Commands; namespace InstarBot.Tests.Integration.Interactions; using Xunit; @@ -14,13 +15,13 @@ public static class PingCommandTests [Fact(DisplayName = "User should be able to issue the Ping command.")] public static async Task PingCommand_Send_ShouldEmitEphemeralPong() { - // Arrange - var command = TestUtilities.SetupCommandMock(); + // Arrange + var cmd = TestOrchestrator.Default.GetCommand(); // Act - await command.Object.Ping(); + await cmd.Object.Ping(); - // Assert - TestUtilities.VerifyMessage(command, "Pong!", true); + // Assert + cmd.VerifyResponse("Pong!", true); } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs index c71b1bf..e4044ec 100644 --- a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -1,6 +1,7 @@ using Discord; using FluentAssertions; -using InstarBot.Tests.Services; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; @@ -11,17 +12,25 @@ namespace InstarBot.Tests.Integration.Interactions; public static class ReportUserTests { + private static async Task SetupOrchestrator(ReportContext context) + { + var orchestrator = TestOrchestrator.Default; + + orchestrator.CreateChannel(context.Channel); + + return orchestrator; + } + [Fact(DisplayName = "User should be able to report a message normally")] public static async Task ReportUser_WhenReportingNormally_ShouldNotifyStaff() { var context = ReportContext.Builder() - .FromUser(Snowflake.Generate()) - .Reporting(Snowflake.Generate()) .InChannel(Snowflake.Generate()) .WithReason("This is a test report") .Build(); - var (command, interactionContext, channelMock) = SetupMocks(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); var verifier = EmbedVerifier.Builder() .WithFooterText(Strings.Embed_UserReport_Footer) @@ -33,95 +42,62 @@ public static async Task ReportUser_WhenReportingNormally_ShouldNotifyStaff() .Build(); // Act - await command.Object.HandleCommand(interactionContext.Object); + await command.Object.HandleCommand(SetupMessageCommandMock(orchestrator, context)); await command.Object.ModalResponse(new ReportMessageModal { ReportReason = context.Reason }); - // Assert - TestUtilities.VerifyMessage(command, Strings.Command_ReportUser_ReportSent, true); - - - TestUtilities.VerifyChannelEmbed(channelMock, verifier, "{0}"); - - - context.ResultEmbed.Should().NotBeNull(); - var embed = context.ResultEmbed; - - embed.Author.Should().NotBeNull(); - embed.Footer.Should().NotBeNull(); - embed.Footer!.Value.Text.Should().Be("Instar Message Reporting System"); + // Assert + command.VerifyResponse(Strings.Command_ReportUser_ReportSent, true); + + ((TestChannel) await orchestrator.Discord.GetChannel(orchestrator.Configuration.StaffAnnounceChannel)).VerifyEmbed(verifier, "{@}"); } [Fact(DisplayName = "Report user function times out if cache expires")] public static async Task ReportUser_WhenReportingNormally_ShouldFail_IfNotCompletedWithin5Minutes() { var context = ReportContext.Builder() - .FromUser(Snowflake.Generate()) - .Reporting(Snowflake.Generate()) .InChannel(Snowflake.Generate()) .WithReason("This is a test report") .Build(); - var (command, interactionContext, _) = SetupMocks(context); + var orchestrator = await SetupOrchestrator(context); + var command = orchestrator.GetCommand(); - // Act - await command.Object.HandleCommand(interactionContext.Object); + // Act + await command.Object.HandleCommand(SetupMessageCommandMock(orchestrator, context)); ReportUserCommand.PurgeCache(); await command.Object.ModalResponse(new ReportMessageModal { ReportReason = context.Reason }); - // Assert - TestUtilities.VerifyMessage(command, Strings.Command_ReportUser_ReportExpired, true); - } - - private static (Mock, Mock, Mock) SetupMocks(ReportContext context) - { - var commandMockContext = new TestContext - { - UserID = context.User, - EmbedCallback = embed => context.ResultEmbed = embed - }; - - var commandMock = - TestUtilities.SetupCommandMock - (() => new ReportUserCommand(TestUtilities.GetDynamicConfiguration(), new MockMetricService()), - commandMockContext); - - return (commandMock, SetupMessageCommandMock(context), commandMockContext.TextChannelMock); + // Assert + command.VerifyResponse(Strings.Command_ReportUser_ReportExpired, true); } - private static Mock SetupMessageCommandMock(ReportContext context) + private static IInstarMessageCommandInteraction SetupMessageCommandMock(TestOrchestrator orchestrator, ReportContext context) { - var userMock = TestUtilities.SetupUserMock(context.User); - var authorMock = TestUtilities.SetupUserMock(context.Sender); - - var channelMock = TestUtilities.SetupChannelMock(context.Channel); - - var messageMock = new Mock(); - messageMock.Setup(n => n.Id).Returns(100); - messageMock.Setup(n => n.Author).Returns(authorMock.Object); - messageMock.Setup(n => n.Channel).Returns(channelMock.Object); + TestChannel testChannel = (TestChannel) orchestrator.Guild.GetTextChannel(context.Channel); + var message = testChannel.AddMessage(orchestrator.Subject, "Naughty message"); - var socketMessageDataMock = new Mock(); - socketMessageDataMock.Setup(n => n.Message).Returns(messageMock.Object); + var socketMessageDataMock = new Mock(); + socketMessageDataMock.Setup(n => n.Message).Returns(message); var socketMessageCommandMock = new Mock(); - socketMessageCommandMock.Setup(n => n.User).Returns(userMock.Object); + socketMessageCommandMock.Setup(n => n.User).Returns(orchestrator.Actor); socketMessageCommandMock.Setup(n => n.Data).Returns(socketMessageDataMock.Object); socketMessageCommandMock.Setup(n => n.RespondWithModalAsync(It.IsAny(), It.IsAny(), It.IsAny>())) .Returns(Task.CompletedTask); - - return socketMessageCommandMock; + + return socketMessageCommandMock.Object; } - private record ReportContext(Snowflake User, Snowflake Sender, Snowflake Channel, string Reason) + private record ReportContext(Snowflake Channel, string Reason) { public static ReportContextBuilder Builder() { @@ -133,31 +109,9 @@ public static ReportContextBuilder Builder() private class ReportContextBuilder { - private Snowflake? _user; - private Snowflake? _sender; private Snowflake? _channel; private string? _reason; - /* - * ReportContext.Builder() - * .FromUser(user) - * .Reporting(userToReport) - * .WithContent(content) - * .InChannel(channel) - * .WithReason(reason); - */ - public ReportContextBuilder FromUser(Snowflake user) - { - _user = user; - return this; - } - - public ReportContextBuilder Reporting(Snowflake userToReport) - { - _sender = userToReport; - return this; - } - public ReportContextBuilder InChannel(Snowflake channel) { _channel = channel; @@ -172,16 +126,12 @@ public ReportContextBuilder WithReason(string reason) public ReportContext Build() { - if (_user is null) - throw new InvalidOperationException("User must be set before building ReportContext"); - if (_sender is null) - throw new InvalidOperationException("Sender must be set before building ReportContext"); if (_channel is null) throw new InvalidOperationException("Channel must be set before building ReportContext"); if (_reason is null) throw new InvalidOperationException("Reason must be set before building ReportContext"); - return new ReportContext(_user, _sender, _channel, _reason); + return new ReportContext(_channel, _reason); } } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs index cf5c450..e7c1c32 100644 --- a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs @@ -1,6 +1,8 @@ -using Discord; +using Amazon.SimpleSystemsManagement.Model; +using Discord; using FluentAssertions; -using InstarBot.Tests.Services; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Services; using Microsoft.Extensions.DependencyInjection; using Moq; using PaxAndromeda.Instar; @@ -15,119 +17,110 @@ namespace InstarBot.Tests.Integration.Interactions; public static class ResetBirthdayCommandTests { - private static async Task<(IInstarDDBService, Mock, IGuildUser, TestContext, InstarDynamicConfiguration cfg)> SetupMocks(Birthday? userBirthday = null, bool throwError = false, bool skipDbInsert = false) + private static async Task SetupOrchestrator(DateTimeOffset? userBirthday = null, bool throwsError = false) { - TestUtilities.SetupLogging(); + var orchestrator = TestOrchestrator.Default; - var ddbService = TestUtilities.GetServices().GetService(); - var cfgService = TestUtilities.GetDynamicConfiguration(); - var cfg = await cfgService.GetConfig(); - var userId = Snowflake.Generate(); + await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); - if (throwError && ddbService is MockInstarDDBService mockDDB) + if (throwsError && orchestrator.Database is TestDatabaseService tds) { - mockDDB.Setup(n => n.GetUserAsync(It.IsAny())).Throws(); + tds.Mock.Setup(n => n.GetUserAsync(It.IsAny())).Throws(); // assert that we're actually throwing an exception - await Assert.ThrowsAsync(async () => await ddbService.GetUserAsync(userId)); + await Assert.ThrowsAsync(async () => await tds.GetUserAsync(orchestrator.Actor.Id)); } - var testContext = new TestContext - { - UserID = userId - }; - - var cmd = TestUtilities.SetupCommandMock(() => new ResetBirthdayCommand(ddbService!, cfgService), testContext); - - await cmd.Object.Context.User!.AddRoleAsync(cfg.NewMemberRoleID); - - cmd.Setup(n => n.Context.User!.GuildId).Returns(TestUtilities.GuildID); - - if (!skipDbInsert) - ((MockInstarDDBService) ddbService!).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); + orchestrator.Subject = orchestrator.CreateUser(); + if (userBirthday is null) + return orchestrator; - ddbService.Should().NotBeNull(); + var dbEntry = InstarUserData.CreateFrom(orchestrator.Subject); + dbEntry.Birthday = new Birthday((DateTimeOffset) userBirthday, orchestrator.TimeProvider); + dbEntry.Birthdate = dbEntry.Birthday.Key; - if (userBirthday is null) - return (ddbService, cmd, cmd.Object.Context.User!, testContext, cfg); + await orchestrator.Database.CreateUserAsync(dbEntry); - var dbUser = await ddbService.GetUserAsync(userId); - dbUser!.Data.Birthday = userBirthday; - dbUser.Data.Birthdate = userBirthday.Key; - await dbUser.UpdateAsync(); - - return (ddbService, cmd, cmd.Object.Context.User!, testContext, cfg); + return orchestrator; } [Fact] public static async Task ResetBirthday_WithEligibleUser_ShouldHaveBirthdayReset() { // Arrange - var (ddb, cmd, user, ctx, _) = await SetupMocks(userBirthday: new Birthday(new DateTime(2000, 1, 1), TimeProvider.System)); + var orchestrator = await SetupOrchestrator(new DateTime(2000, 1, 1)); + var cmd = orchestrator.GetCommand(); + // Act - await cmd.Object.ResetBirthday(user); + await cmd.Object.ResetBirthday(orchestrator.Subject); // Assert - var dbUser = await ddb.GetUserAsync(user.Id); + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); dbUser.Should().NotBeNull(); dbUser.Data.Birthday.Should().BeNull(); dbUser.Data.Birthdate.Should().BeNull(); - TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Success, ephemeral: true); - TestUtilities.VerifyChannelMessage(ctx.DMChannelMock, Strings.Command_ResetBirthday_EndUserNotification); + cmd.VerifyResponse(Strings.Command_ResetBirthday_Success, ephemeral: true); + orchestrator.Subject.DMChannelMock.VerifyMessage(Strings.Command_ResetBirthday_EndUserNotification); } [Fact] public static async Task ResetBirthday_UserNotFound_ShouldEmitError() { // Arrange - var (ddb, cmd, user, ctx, _) = await SetupMocks(skipDbInsert: true); + var orchestrator = await SetupOrchestrator(); + var cmd = orchestrator.GetCommand(); // Act - await cmd.Object.ResetBirthday(user); + await cmd.Object.ResetBirthday(orchestrator.Subject); // Assert - var dbUser = await ddb.GetUserAsync(user.Id); + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); dbUser.Should().BeNull(); - TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Error_UserNotFound, ephemeral: true); + cmd.VerifyResponse(Strings.Command_ResetBirthday_Error_UserNotFound, ephemeral: true); } [Fact] public static async Task ResetBirthday_UserHasBirthdayRole_ShouldRemoveRole() { // Arrange - var (ddb, cmd, user, ctx, cfg) = await SetupMocks(userBirthday: new Birthday(new DateTime(2000, 1, 1), TimeProvider.System)); + var orchestrator = await SetupOrchestrator(new DateTime(2000, 1, 1)); + var birthdayRole = orchestrator.Configuration.BirthdayConfig.BirthdayRole; + + var cmd = orchestrator.GetCommand(); + + await orchestrator.Subject.AddRoleAsync(birthdayRole); - await user.AddRoleAsync(cfg.BirthdayConfig.BirthdayRole); - user.RoleIds.Should().Contain(cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().Contain(birthdayRole); // Act - await cmd.Object.ResetBirthday(user); + await cmd.Object.ResetBirthday(orchestrator.Subject); // Assert - var dbUser = await ddb.GetUserAsync(user.Id); + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); dbUser.Should().NotBeNull(); dbUser.Data.Birthday.Should().BeNull(); dbUser.Data.Birthdate.Should().BeNull(); - user.RoleIds.Should().NotContain(cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().NotContain(birthdayRole); - TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Success, ephemeral: true); - TestUtilities.VerifyChannelMessage(ctx.DMChannelMock, Strings.Command_ResetBirthday_EndUserNotification); + cmd.VerifyResponse(Strings.Command_ResetBirthday_Success, ephemeral: true); + orchestrator.Subject.DMChannelMock.VerifyMessage(Strings.Command_ResetBirthday_EndUserNotification); } [Fact] public static async Task ResetBirthday_WithDBError_ShouldEmitError() { // Arrange - var (ddb, cmd, user, ctx, _) = await SetupMocks(throwError: true); + var orchestrator = await SetupOrchestrator(throwsError: true); + var cmd = orchestrator.GetCommand(); // Act - await cmd.Object.ResetBirthday(user); + await cmd.Object.ResetBirthday(orchestrator.Subject); // Assert - TestUtilities.VerifyMessage(cmd, Strings.Command_ResetBirthday_Error_Unknown, ephemeral: true); + cmd.VerifyResponse(Strings.Command_ResetBirthday_Error_Unknown, ephemeral: true); } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs index 36989a4..1b19bc0 100644 --- a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -1,7 +1,9 @@ using Discord; using FluentAssertions; -using InstarBot.Tests.Services; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Testing.Platform.Extensions.Messages; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; @@ -14,15 +16,15 @@ namespace InstarBot.Tests.Integration.Interactions; public static class SetBirthdayCommandTests { - private static async Task<(IInstarDDBService, Mock, InstarDynamicConfiguration)> SetupMocks(SetBirthdayContext context, DateTime? timeOverride = null, bool throwError = false) - { - TestUtilities.SetupLogging(); + /*private static async Task<(IDatabaseService, Mock, InstarDynamicConfiguration)> SetupOrchestrator(SetBirthdayContext context, DateTime? timeOverride = null, bool throwError = false) + { + TestUtilities.SetupLogging(); var timeProvider = TimeProvider.System; if (timeOverride is not null) { var timeProviderMock = new Mock(); - timeProviderMock.Setup(n => n.GetUtcNow()).Returns(new DateTimeOffset((DateTime)timeOverride)); + timeProviderMock.Setup(n => n.GetUtcNow()).Returns(new DateTimeOffset((DateTime) timeOverride)); timeProvider = timeProviderMock.Object; } @@ -31,7 +33,7 @@ public static class SetBirthdayCommandTests context.StaffAnnounceChannel = staffAnnounceChannelMock; context.BirthdayAnnounceChannel = birthdayAnnounceChannelMock; - var ddbService = TestUtilities.GetServices().GetService(); + var ddbService = TestUtilities.GetServices().GetService(); var cfgService = TestUtilities.GetDynamicConfiguration(); var cfg = await cfgService.GetConfig(); @@ -57,7 +59,7 @@ public static class SetBirthdayCommandTests var birthdaySystem = new BirthdaySystem(cfgService, discord, ddbService, new MockMetricService(), timeProvider); - if (throwError && ddbService is MockInstarDDBService mockDDB) + if (throwError && ddbService is MockDatabaseService mockDDB) mockDDB.Setup(n => n.GetOrCreateUserAsync(It.IsAny())).Throws(); var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService, TestUtilities.GetDynamicConfiguration(), new MockMetricService(), birthdaySystem, timeProvider), testContext); @@ -65,20 +67,38 @@ public static class SetBirthdayCommandTests await cmd.Object.Context.User!.AddRoleAsync(cfg.NewMemberRoleID); cmd.Setup(n => n.Context.User!.GuildId).Returns(TestUtilities.GuildID); - + context.User = cmd.Object.Context.User!; cmd.Setup(n => n.Context.Guild).Returns(guildMock.Object); - ((MockInstarDDBService) ddbService).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); + ((MockDatabaseService) ddbService).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); ddbService.Should().NotBeNull(); - return (ddbService, cmd, cfg); - } + return (ddbService, cmd, cfg); + }*/ + + private const ulong NewMemberRole = 796052052433698817ul; + + private static async Task SetupOrchestrator(bool throwError = false) + { + var orchestrator = TestOrchestrator.Default; + await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); + + if (throwError) + { + if (orchestrator.Database is not IMockOf ddbService) + throw new InvalidOperationException("IDatabaseService was not mocked correctly."); + ddbService.Mock.Setup(n => n.GetOrCreateUserAsync(It.IsAny())).Throws(); + } + + return orchestrator; + } - [Theory(DisplayName = "UserID should be able to set their birthday when providing a valid date.")] + + [Theory(DisplayName = "UserID should be able to set their birthday when providing a valid date.")] [InlineData(1992, 7, 21, 0)] [InlineData(1992, 7, 21, -7)] [InlineData(1992, 7, 21, 7)] @@ -87,22 +107,25 @@ public static class SetBirthdayCommandTests [InlineData(2010, 1, 1, 0)] public static async Task SetBirthdayCommand_WithValidDate_ShouldSetCorrectly(int year, int month, int day, int timezone) { - // Arrange - var context = new SetBirthdayContext(Snowflake.Generate(), year, month, day, timezone); + // Arrange + var date = new DateTimeOffset(year, month, day, 0, 0, 0, 0, TimeSpan.FromHours(timezone)); - var (ddb, cmd, _) = await SetupMocks(context); + var orchestrator = await SetupOrchestrator(); + orchestrator.SetTime(date); + var cmd = orchestrator.GetCommand(); - // Act - await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); + // Act + await cmd.Object.SetBirthday((Month)month, day, year, timezone); - // Assert - var date = context.ToDateTime(); - - var ddbUser = await ddb.GetUserAsync(context.UserID.ID); + // Assert + + var database = orchestrator.GetService(); + + var ddbUser = await database.GetUserAsync(orchestrator.Actor.Id); ddbUser!.Data.Birthday.Should().NotBeNull(); ddbUser.Data.Birthday.Birthdate.UtcDateTime.Should().Be(date.UtcDateTime); ddbUser.Data.Birthdate.Should().Be(date.UtcDateTime.ToString("MMddHHmm")); - TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Success, true); + cmd.VerifyResponse(Strings.Command_SetBirthday_Success, true); } [Theory(DisplayName = "Attempting to set an invalid day or month number should emit an error message.")] @@ -114,28 +137,25 @@ public static async Task SetBirthdayCommand_WithValidDate_ShouldSetCorrectly(int [InlineData(2032, 2, 31)] // Leap year public static async Task SetBirthdayCommand_WithInvalidDate_ShouldReturnError(int year, int month, int day) { - // Arrange - var context = new SetBirthdayContext(Snowflake.Generate(), year, month, day); - - var (_, cmd, _) = await SetupMocks(context); + // Arrange + var orchestrator = await SetupOrchestrator(); + var cmd = orchestrator.GetCommand(); - // Act - await cmd.Object.SetBirthday((Month)context.Month, context.Day, context.Year, context.TimeZone); + // Act + await cmd.Object.SetBirthday((Month)month, day, year); // Assert if (month is < 0 or > 12) { - TestUtilities.VerifyMessage(cmd, - Strings.Command_SetBirthday_MonthsOutOfRange, true); + cmd.VerifyResponse(Strings.Command_SetBirthday_MonthsOutOfRange, true); } else { - var date = new DateTime(context.Year, context.Month, 1); // there's always a 1st of the month - var daysInMonth = DateTime.DaysInMonth(context.Year, context.Month); + var date = new DateTime(year, month, 1); // there's always a 1st of the month + var daysInMonth = DateTime.DaysInMonth(year, month); - // Assert - TestUtilities.VerifyMessage(cmd, - Strings.Command_SetBirthday_DaysInMonthOutOfRange, true); + // Assert + cmd.VerifyResponse(Strings.Command_SetBirthday_DaysInMonthOutOfRange, true); } } @@ -143,82 +163,76 @@ public static async Task SetBirthdayCommand_WithInvalidDate_ShouldReturnError(in public static async Task SetBirthdayCommand_WithDateInFuture_ShouldReturnError() { // Arrange - // Note: Update this in the year 9,999 - var context = new SetBirthdayContext(Snowflake.Generate(), 9999, 1, 1); - - var (_, cmd, _) = await SetupMocks(context); + var orchestrator = await SetupOrchestrator(); + var cmd = orchestrator.GetCommand(); // Act - await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + await cmd.Object.SetBirthday(Month.January, 1, 9999); // Assert - TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_NotTimeTraveler, true); + cmd.VerifyResponse(Strings.Command_SetBirthday_NotTimeTraveler, true); } [Fact(DisplayName = "Attempting to set a birthday when user has already set one should emit an error message.")] public static async Task SetBirthdayCommand_BirthdayAlreadyExists_ShouldReturnError() { // Arrange - // Note: Update this in the year 9,999 - var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); + var orchestrator = await SetupOrchestrator(); + var cmd = orchestrator.GetCommand(); - var (ddb, cmd, _) = await SetupMocks(context); - - var dbUser = await ddb.GetOrCreateUserAsync(context.User); + var database = orchestrator.GetService(); + var dbUser = await database.GetOrCreateUserAsync(orchestrator.Actor); dbUser.Data.Birthday = new Birthday(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc, TimeProvider.System); dbUser.Data.Birthdate = dbUser.Data.Birthday.Key; - await dbUser.UpdateAsync(); + await dbUser.CommitAsync(); // Act - await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + await cmd.Object.SetBirthday(Month.January, 1, 2000); // Assert - TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Error_AlreadySet, true); + cmd.VerifyResponse(Strings.Command_SetBirthday_Error_AlreadySet, true); } [Fact(DisplayName = "An exception should return a message.")] public static async Task SetBirthdayCommand_WithException_ShouldPromptUserToTryAgainLater() { // Arrange - // Note: Update this in the year 9,999 - var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); - - var (_, cmd, _) = await SetupMocks(context, throwError: true); + var orchestrator = await SetupOrchestrator(throwError: true); + var cmd = orchestrator.GetCommand(); // Act - await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + await cmd.Object.SetBirthday(Month.January, 1, 1); // Assert - TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Error_CouldNotSetBirthday, true); + cmd.VerifyResponse(Strings.Command_SetBirthday_Error_CouldNotSetBirthday, true); } [Fact(DisplayName = "Attempting to set an underage birthday should result in an AMH and staff notification.")] public static async Task SetBirthdayCommand_WithUnderage_ShouldNotifyStaff() { // Arrange - // Note: Update this in the year 9,999 - var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); - - var (ddb, cmd, cfg) = await SetupMocks(context, new DateTime(2010, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var orchestrator = await SetupOrchestrator(); + orchestrator.SetTime(new DateTime(2010, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var cmd = orchestrator.GetCommand(); // Act - await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + await cmd.Object.SetBirthday(Month.January, 1, 2000); // Assert - var date = context.ToDateTime(); + var date = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero); - var ddbUser = await ddb.GetUserAsync(context.UserID.ID); + var ddbUser = await orchestrator.Database.GetUserAsync(orchestrator.Actor.Id); ddbUser!.Data.Birthday.Should().NotBeNull(); ddbUser.Data.Birthday.Birthdate.UtcDateTime.Should().Be(date.UtcDateTime); ddbUser.Data.Birthdate.Should().Be(date.UtcDateTime.ToString("MMddHHmm")); ddbUser.Data.AutoMemberHoldRecord.Should().NotBeNull(); - ddbUser.Data.AutoMemberHoldRecord!.ModeratorID.Should().Be(cfg.BotUserID); + ddbUser.Data.AutoMemberHoldRecord!.ModeratorID.Should().Be(orchestrator.Configuration.BotUserID); - TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Success, true); + cmd.VerifyResponse(Strings.Command_SetBirthday_Success, true); - var staffAnnounceChannel = cmd.Object.Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel); + var staffAnnounceChannel = cmd.Object.Context.Guild.GetTextChannel(orchestrator.Configuration.StaffAnnounceChannel); staffAnnounceChannel.Should().NotBeNull(); @@ -226,53 +240,39 @@ public static async Task SetBirthdayCommand_WithUnderage_ShouldNotifyStaff() var embedVerifier = EmbedVerifier.Builder() .WithDescription(Strings.Embed_UnderageUser_WarningTemplate_NewMember).Build(); - TestUtilities.VerifyChannelEmbed(context.StaffAnnounceChannel, embedVerifier, $"<@&{cfg.StaffRoleID}>"); + orchestrator.GetChannel(orchestrator.Configuration.StaffAnnounceChannel) + .VerifyEmbed(embedVerifier, $"<@&{orchestrator.Configuration.StaffRoleID}>"); } [Fact(DisplayName = "Attempting to set a birthday to today should grant the birthday role.")] public static async Task SetBirthdayCommand_BirthdayIsToday_ShouldGrantBirthdayRoles() { // Arrange - // Note: Update this in the year 9,999 - var context = new SetBirthdayContext(Snowflake.Generate(), 2000, 1, 1); + var date = new DateTimeOffset(new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc)); - var (ddb, cmd, cfg) = await SetupMocks(context, new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var orchestrator = await SetupOrchestrator(); + orchestrator.SetTime(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var cmd = orchestrator.GetCommand(); // Act - await cmd.Object.SetBirthday((Month) context.Month, context.Day, context.Year, context.TimeZone); + await cmd.Object.SetBirthday((Month) date.Month, date.Day, date.Year); // Assert - var date = context.ToDateTime(); - - context.User.RoleIds.Should().Contain(cfg.BirthdayConfig.BirthdayRole); + orchestrator.Actor.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); - var ddbUser = await ddb.GetUserAsync(context.UserID.ID); + var ddbUser = await orchestrator.Database.GetUserAsync(orchestrator.Actor.Id); ddbUser!.Data.Birthday.Should().NotBeNull(); ddbUser.Data.Birthday.Birthdate.UtcDateTime.Should().Be(date.UtcDateTime); ddbUser.Data.Birthdate.Should().Be(date.UtcDateTime.ToString("MMddHHmm")); ddbUser.Data.AutoMemberHoldRecord.Should().BeNull(); - TestUtilities.VerifyMessage(cmd, Strings.Command_SetBirthday_Success, true); + cmd.VerifyResponse(Strings.Command_SetBirthday_Success, true); - var birthdayAnnounceChannel = cmd.Object.Context.Guild.GetTextChannel(cfg.BirthdayConfig.BirthdayAnnounceChannel); + var birthdayAnnounceChannel = cmd.Object.Context.Guild.GetTextChannel(orchestrator.Configuration.BirthdayConfig.BirthdayAnnounceChannel); birthdayAnnounceChannel.Should().NotBeNull(); - - TestUtilities.VerifyChannelMessage(context.BirthdayAnnounceChannel, Strings.Birthday_Announcement); - } - - private record SetBirthdayContext(Snowflake UserID, int Year, int Month, int Day, int TimeZone = 0) - { - public DateTimeOffset ToDateTime() - { - var unspecifiedDate = new DateTime(Year, Month, Day, 0, 0, 0, DateTimeKind.Unspecified); - var timeZone = new DateTimeOffset(unspecifiedDate, TimeSpan.FromHours(TimeZone)); - return timeZone; - } - - public Mock StaffAnnounceChannel { get; set; } - public IGuildUser User { get; set; } - public Mock BirthdayAnnounceChannel { get ; set ; } - } + orchestrator.GetChannel(orchestrator.Configuration.BirthdayConfig.BirthdayAnnounceChannel) + .VerifyMessage(Strings.Birthday_Announcement); + } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs index 40876a5..aceaef1 100644 --- a/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs +++ b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs @@ -1,6 +1,9 @@ using FluentAssertions; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; +using InstarBot.Test.Framework.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.DynamoModels; @@ -8,6 +11,7 @@ using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Services; using Xunit; +using static Microsoft.ApplicationInsights.MetricDimensionNames.TelemetryContext; namespace InstarBot.Tests.Integration.Services; @@ -20,85 +24,70 @@ public static class AutoMemberSystemTests private static readonly Snowflake SheHer = new(796578609535647765); private static readonly Snowflake AutoMemberHold = new(966434762032054282); - private static async Task SetupTest(AutoMemberSystemContext scenarioContext) - { - var testContext = scenarioContext.TestContext; - - var discordService = TestUtilities.SetupDiscordService(testContext); - var gaiusApiService = TestUtilities.SetupGaiusAPIService(testContext); - var config = TestUtilities.GetDynamicConfiguration(); - - scenarioContext.DiscordService = discordService; - var userId = scenarioContext.UserID; - var relativeJoinTime = scenarioContext.HoursSinceJoined; - var roles = scenarioContext.Roles; - var postedIntro = scenarioContext.PostedIntroduction; - var messagesLast24Hours = scenarioContext.MessagesLast24Hours; - var firstSeenTime = scenarioContext.FirstJoinTime; - var grantedMembershipBefore = scenarioContext.GrantedMembershipBefore; - - var amsConfig = scenarioContext.Config.AutoMemberConfig; + private static async Task SetupOrchestrator(AutoMemberSystemContext context) + { + var orchestrator = await TestOrchestrator.Builder + .WithSubject(new TestGuildUser + { + Username = "username", + JoinedAt = DateTimeOffset.Now - TimeSpan.FromHours(context.HoursSinceJoined), + RoleIds = context.Roles.Select(n => n.ID).ToList().AsReadOnly() + }) + .WithService() + .Build(); - var ddbService = new MockInstarDDBService(); + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); + var channel = orchestrator.CreateChannel(Snowflake.Generate()); - var user = new TestGuildUser + if (context.PostedIntroduction) { - Id = userId, - Username = "username", - JoinedAt = DateTimeOffset.Now - TimeSpan.FromHours(relativeJoinTime), - RoleIds = roles.Select(n => n.ID).ToList().AsReadOnly() - }; - - var userData = InstarUserData.CreateFrom(user); - userData.Position = grantedMembershipBefore ? InstarUserPosition.Member : InstarUserPosition.NewMember; - - if (!scenarioContext.SuppressDDBEntry) - ddbService.Register(userData); - - testContext.AddRoles(roles); - - testContext.GuildUsers.Add(user); - - - var genericChannel = Snowflake.Generate(); - testContext.AddChannel(amsConfig.IntroductionChannel); - - testContext.AddChannel(genericChannel); - if (postedIntro) - ((TestChannel) testContext.GetChannel(amsConfig.IntroductionChannel)).AddMessage(user, "Some text"); + TestChannel introChannel = (TestChannel) await orchestrator.Discord.GetChannel(orchestrator.Configuration.AutoMemberConfig.IntroductionChannel); + introChannel.AddMessage(orchestrator.Subject, "Some introduction"); + } + + for (var i = 0; i < context.MessagesLast24Hours; i++) + channel.AddMessage(orchestrator.Subject, "Some text"); - for (var i = 0; i < messagesLast24Hours; i++) - ((TestChannel)testContext.GetChannel(genericChannel)).AddMessage(user, "Some text"); + if (context.GrantedMembershipBefore) + { + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); + dbUser.Data.Position = InstarUserPosition.Member; + await dbUser.CommitAsync(); + } + if (context.GaiusInhibited) + { + TestGaiusAPIService gaiusMock = (TestGaiusAPIService) orchestrator.GetService(); + gaiusMock.Inhibit(); + } - var ams = new AutoMemberSystem(config, discordService, gaiusApiService, ddbService, new MockMetricService(), TimeProvider.System); + var ams = (AutoMemberSystem) orchestrator.GetService(); await ams.Initialize(); - scenarioContext.User = user; - scenarioContext.DynamoService = ddbService; - - return ams; - } + orchestrator.Subject.Reset(); + return orchestrator; + } [Fact(DisplayName = "Eligible users should be granted membership")] public static async Task AutoMemberSystem_EligibleUser_ShouldBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); // Act await ams.RunAsync(); // Assert - context.AssertMember(); + context.AssertMember(orchestrator); } @@ -106,20 +95,21 @@ public static async Task AutoMemberSystem_EligibleUser_ShouldBeGrantedMembership public static async Task AutoMemberSystem_EligibleUserWithAMH_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer, AutoMemberHold) .HasPostedIntroduction() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } @@ -127,146 +117,153 @@ public static async Task AutoMemberSystem_EligibleUserWithAMH_ShouldNotBeGranted public static async Task AutoMemberSystem_NewUser_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(12)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "Inactive users should not be granted membership.")] public static async Task AutoMemberSystem_InactiveUser_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() .WithMessages(10) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "Auto Member System should not affect Members.")] public static async Task AutoMemberSystem_Member_ShouldNotBeChanged() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(Member, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertUserUnchanged(); + context.AssertUserUnchanged(orchestrator); } [Fact(DisplayName = "A user that did not post an introduction should not be granted membership")] public static async Task AutoMemberSystem_NoIntroduction_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .WithMessages(100) .Build(); - var ams = await SetupTest(context); - - // Act - await ams.RunAsync(); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); + // Act + await ams.RunAsync(); + // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "A user without an age role should not be granted membership")] public static async Task AutoMemberSystem_NoAgeRole_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, SheHer) .HasPostedIntroduction() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "A user without a gender role should not be granted membership")] public static async Task AutoMemberSystem_NoGenderRole_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, TwentyOnePlus, SheHer) .HasPostedIntroduction() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "A user without a pronoun role should not be granted membership")] public static async Task AutoMemberSystem_NoPronounRole_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus) .HasPostedIntroduction() .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "A user with a warning should not be granted membership")] public static async Task AutoMemberSystem_UserWithGaiusWarning_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() @@ -274,20 +271,32 @@ public static async Task AutoMemberSystem_UserWithGaiusWarning_ShouldNotBeGrante .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = (AutoMemberSystem) orchestrator.GetService(); - // Act - await ams.RunAsync(); + TestGaiusAPIService gaiusMock = (TestGaiusAPIService) orchestrator.GetService(); + gaiusMock.AddWarning(orchestrator.Subject, new Warning + { + Reason = "TEST PUNISHMENT", + ModID = Snowflake.Generate(), + UserID = orchestrator.Subject.Id + }); + + // reload the Gaius warnings + await ams.Initialize(); + + // Act + await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "A user with a caselog should not be granted membership")] public static async Task AutoMemberSystem_UserWithGaiusCaselog_ShouldNotBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() @@ -295,20 +304,33 @@ public static async Task AutoMemberSystem_UserWithGaiusCaselog_ShouldNotBeGrante .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = (AutoMemberSystem) orchestrator.GetService(); + + TestGaiusAPIService gaiusMock = (TestGaiusAPIService) orchestrator.GetService(); + gaiusMock.AddCaselog(orchestrator.Subject, new Caselog + { + Type = CaselogType.Mute, + Reason = "TEST PUNISHMENT", + ModID = Snowflake.Generate(), + UserID = orchestrator.Subject.Id + }); + + // reload the Gaius caselogs + await ams.Initialize(); // Act await ams.RunAsync(); // Assert - context.AssertNotMember(); + context.AssertNotMember(orchestrator); } [Fact(DisplayName = "A user with a join age auto kick should be granted membership")] public static async Task AutoMemberSystem_UserWithJoinAgeKick_ShouldBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() @@ -316,20 +338,33 @@ public static async Task AutoMemberSystem_UserWithJoinAgeKick_ShouldBeGrantedMem .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = (AutoMemberSystem) orchestrator.GetService(); + + TestGaiusAPIService gaiusMock = (TestGaiusAPIService) orchestrator.GetService(); + gaiusMock.AddCaselog(orchestrator.Subject, new Caselog + { + Type = CaselogType.Kick, + Reason = "Join age punishment", + ModID = Snowflake.Generate(), + UserID = orchestrator.Subject.Id + }); + + // reload the Gaius caselogs + await ams.Initialize(); // Act await ams.RunAsync(); // Assert - context.AssertMember(); + context.AssertMember(orchestrator); } [Fact(DisplayName = "A user should be granted membership if Gaius is unavailable")] public static async Task AutoMemberSystem_GaiusIsUnavailable_ShouldBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() @@ -337,20 +372,21 @@ public static async Task AutoMemberSystem_GaiusIsUnavailable_ShouldBeGrantedMemb .WithMessages(100) .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = (AutoMemberSystem) orchestrator.GetService(); - // Act - await ams.RunAsync(); + // Act + await ams.RunAsync(); // Assert - context.AssertMember(); + context.AssertMember(orchestrator); } [Fact(DisplayName = "A user should be granted membership if they have been granted membership before")] public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembership() { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(1)) .FirstJoined(TimeSpan.FromDays(7)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) @@ -359,11 +395,12 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe .WithMessages(100) .Build(); - await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); - // Act - var service = context.DiscordService as MockDiscordService; - var user = context.User; + // Act + var service = orchestrator.Discord as TestDiscordService; + var user = orchestrator.Subject; service.Should().NotBeNull(); user.Should().NotBeNull(); @@ -371,7 +408,7 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe await service.TriggerUserJoined(user); // Assert - context.AssertMember(); + context.AssertMember(orchestrator); } [Fact(DisplayName = "The Dynamo record for a user should be updated when the user's username changes")] @@ -380,7 +417,7 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte // Arrange const string newUsername = "fred"; - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(1)) .FirstJoined(TimeSpan.FromDays(7)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) @@ -389,22 +426,22 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte .WithMessages(100) .Build(); - await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); // Make sure the user is in the database - context.DynamoService.Should().NotBeNull(because: "Test is invalid if DynamoService is not set"); - await context.DynamoService.CreateUserAsync(InstarUserData.CreateFrom(context.User!)); + await orchestrator.Database.CreateUserAsync(InstarUserData.CreateFrom(orchestrator.Subject)); // Act - MockDiscordService mds = (MockDiscordService) context.DiscordService!; + var mds = (TestDiscordService) orchestrator.Discord; - var newUser = context.User!.Clone(); + var newUser = orchestrator.Subject.Clone(); newUser.Username = newUsername; - await mds.TriggerUserUpdated(new UserUpdatedEventArgs(context.UserID, context.User, newUser)); + await mds.TriggerUserUpdated(new UserUpdatedEventArgs(orchestrator.Subject.Id, orchestrator.Subject, newUser)); // Assert - var ddbUser = await context.DynamoService.GetUserAsync(context.UserID); + var ddbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); ddbUser.Should().NotBeNull(); ddbUser.Data.Username.Should().Be(newUsername); @@ -419,7 +456,7 @@ public static async Task AutoMemberSystem_MemberEligibleButMissingInDDB_ShouldBe { // Arrange - var context = await AutoMemberSystemContext.Builder() + var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() @@ -427,17 +464,17 @@ public static async Task AutoMemberSystem_MemberEligibleButMissingInDDB_ShouldBe .SuppressDDBEntry() .Build(); - var ams = await SetupTest(context); + var orchestrator = await SetupOrchestrator(context); + var ams = orchestrator.GetService(); // Act await ams.RunAsync(); // Assert - context.AssertMember(); + context.AssertMember(orchestrator); } private record AutoMemberSystemContext( - Snowflake UserID, int HoursSinceJoined, Snowflake[] Roles, bool PostedIntroduction, @@ -445,33 +482,28 @@ private record AutoMemberSystemContext( int FirstJoinTime, bool GrantedMembershipBefore, bool SuppressDDBEntry, - TestContext TestContext, - InstarDynamicConfiguration Config) + bool GaiusInhibited) { public static AutoMemberSystemContextBuilder Builder() => new(); - public IDiscordService? DiscordService { get; set; } - public TestGuildUser? User { get; set; } - public IInstarDDBService? DynamoService { get ; set ; } - - public void AssertMember() + public void AssertMember(TestOrchestrator orchestrator) { - User.Should().NotBeNull(); - User.RoleIds.Should().Contain(Member.ID); - User.RoleIds.Should().NotContain(NewMember.ID); + orchestrator.Subject.Should().NotBeNull(); + orchestrator.Subject.RoleIds.Should().Contain(Member.ID); + orchestrator.Subject.RoleIds.Should().NotContain(NewMember.ID); } - public void AssertNotMember() + public void AssertNotMember(TestOrchestrator orchestrator) { - User.Should().NotBeNull(); - User.RoleIds.Should().NotContain(Member.ID); - User.RoleIds.Should().Contain(NewMember.ID); + orchestrator.Subject.Should().NotBeNull(); + orchestrator.Subject.RoleIds.Should().NotContain(Member.ID); + orchestrator.Subject.RoleIds.Should().Contain(NewMember.ID); } - public void AssertUserUnchanged() + public void AssertUserUnchanged(TestOrchestrator orchestrator) { - User.Should().NotBeNull(); - User.Changed.Should().BeFalse(); + orchestrator.Subject.Should().NotBeNull(); + orchestrator.Subject.Changed.Should().BeFalse(); } } @@ -495,6 +527,7 @@ public AutoMemberSystemContextBuilder Joined(TimeSpan timeAgo) _hoursSinceJoined = (int) Math.Round(timeAgo.TotalHours); return this; } + public AutoMemberSystemContextBuilder SetRoles(params Snowflake[] roles) { _roles = roles; @@ -551,39 +584,9 @@ public AutoMemberSystemContextBuilder SuppressDDBEntry() return this; } - public async Task Build() + public AutoMemberSystemContext Build() { - var config = await TestUtilities.GetDynamicConfiguration().GetConfig(); - - var testContext = new TestContext(); - - var userId = Snowflake.Generate(); - - // Set up any warnings or whatnot - testContext.InhibitGaius = !_gaiusAvailable; - - if (_gaiusPunished) - { - testContext.AddCaselog(userId, new Caselog - { - Type = _joinAgeKick ? CaselogType.Kick : CaselogType.Mute, - Reason = _joinAgeKick ? "Join age punishment" : "TEST PUNISHMENT", - ModID = Snowflake.Generate(), - UserID = userId - }); - } - if (_gaiusWarned) - { - testContext.AddWarning(userId, new Warning - { - Reason = "TEST PUNISHMENT", - ModID = Snowflake.Generate(), - UserID = userId - }); - } - return new AutoMemberSystemContext( - userId, _hoursSinceJoined, _roles ?? throw new InvalidOperationException("Roles must be set."), _postedIntroduction, @@ -591,8 +594,7 @@ public async Task Build() _firstJoinTime, _grantedMembershipBefore, _suppressDDB, - testContext, - config); + _gaiusAvailable); } } } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs index 6ef494e..3ced526 100644 --- a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs +++ b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs @@ -1,11 +1,9 @@ -using Amazon.DynamoDBv2.DataModel; -using FluentAssertions; -using InstarBot.Tests.Models; -using InstarBot.Tests.Services; +using FluentAssertions; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.ConfigModels; -using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; using Xunit; using Metric = PaxAndromeda.Instar.Metrics.Metric; @@ -14,91 +12,39 @@ namespace InstarBot.Tests.Integration.Services; public static class BirthdaySystemTests { - private static async Task Setup(DateTime todayLocal, DateTimeOffset? birthdate = null, bool applyBirthday = false, Func, InstarDynamicConfiguration, List>? roleUpdateFn = null) + private static async Task SetupOrchestrator(DateTimeOffset currentTime, DateTimeOffset? birthdate = null) { - var today = todayLocal.ToUniversalTime(); - var timeProviderMock = new Mock(); - timeProviderMock.Setup(n => n.GetUtcNow()).Returns(today); + var orchestrator = await TestOrchestrator.Builder + .WithTime(currentTime) + .WithService() + .Build(); - Birthday? birthday = birthdate is null ? null : new Birthday(birthdate.Value, timeProviderMock.Object); - - var testUserId = Snowflake.Generate(); - var cfg = TestUtilities.GetDynamicConfiguration(); - var mockDDB = new MockInstarDDBService(); - var metrics = new MockMetricService(); - var polledCfg = await cfg.GetConfig(); - - var discord = TestUtilities.SetupDiscordService(new TestContext + if (birthdate is not null) { - UserID = Snowflake.Generate(), - Channels = - { - { polledCfg.BirthdayConfig.BirthdayAnnounceChannel, new TestChannel(polledCfg.BirthdayConfig.BirthdayAnnounceChannel) } - } - }) as MockDiscordService; - - discord.Should().NotBeNull(); - - var user = new TestGuildUser - { - Id = testUserId, - Username = "TestUser" - }; - - var rolesToAdd = new List(); // Some random role - - if (applyBirthday) - rolesToAdd.Add(polledCfg.BirthdayConfig.BirthdayRole); + var dbUser = await orchestrator.Database.GetOrCreateUserAsync(orchestrator.Subject); + dbUser.Data.Birthday = new Birthday((DateTimeOffset) birthdate, orchestrator.TimeProvider); + dbUser.Data.Birthdate = dbUser.Data.Birthday.Key; + await dbUser.CommitAsync(); - if (birthday is not null) - { - int priorYearsOld = birthday.Age - 1; - var ageRole = polledCfg.BirthdayConfig.AgeRoleMap + int priorYearsOld = dbUser.Data.Birthday.Age - 1; + var ageRole = orchestrator.Configuration.BirthdayConfig.AgeRoleMap .OrderByDescending(n => n.Age) .SkipWhile(n => n.Age > priorYearsOld) .First(); - rolesToAdd.Add(ageRole.Role.ID); - } - - if (roleUpdateFn is not null) - rolesToAdd = roleUpdateFn(rolesToAdd, polledCfg); - - await user.AddRolesAsync(rolesToAdd); - - - discord.AddUser(user); - - var dbUser = InstarUserData.CreateFrom(user); - - if (birthday is not null) - { - dbUser.Birthday = birthday; - dbUser.Birthdate = birthday.Key; + await orchestrator.Subject.AddRoleAsync(ageRole.Role.ID); - if (birthday.IsToday) + if (dbUser.Data.Birthday.IsToday) { - mockDDB.Setup(n => n.GetUsersByBirthday(today, It.IsAny())) - .ReturnsAsync([new InstarDatabaseEntry(Mock.Of(), dbUser)]); + var mockDDB = (IMockOf) orchestrator.Database; + mockDDB.Mock.Setup(n => n.GetUsersByBirthday(currentTime.UtcDateTime, It.IsAny())) + .ReturnsAsync([dbUser]); } } - await mockDDB.CreateUserAsync(dbUser); + await ((BirthdaySystem) orchestrator.GetService()).Initialize(); - - - var birthdaySystem = new BirthdaySystem(cfg, discord, mockDDB, metrics, timeProviderMock.Object); - - return new Context(testUserId, birthdaySystem, mockDDB, discord, metrics, polledCfg, birthday); - } - - private static bool IsDateMatch(DateTime a, DateTime b) - { - // Match everything but year - var aUtc = a.ToUniversalTime(); - var bUtc = b.ToUniversalTime(); - - return aUtc.Month == bUtc.Month && aUtc.Day == bUtc.Day && aUtc.Hour == bUtc.Hour; + return orchestrator; } [Fact] @@ -108,29 +54,27 @@ public static async Task BirthdaySystem_WhenUserBirthday_ShouldGrantRole() var birthday = DateTime.Parse("2000-02-14T00:00:00Z"); var today = DateTime.Parse("2025-02-14T00:00:00Z"); - var ctx = await Setup(today, birthday); + var orchestrator = await SetupOrchestrator(today, birthday); + var system = orchestrator.GetService(); // Act - await ctx.System.RunAsync(); + await system.RunAsync(); // Assert // We expect a few things here: the test user should now have the birthday // role, and there should now be a message in the birthday announce channel. + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.AgeRoleMap.MaxBy(n => n.Age)!.Role.ID); - var user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); - user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.AgeRoleMap.MaxBy(n => n.Age)!.Role.ID); - - var channel = await ctx.Discord.GetChannel(ctx.Cfg.BirthdayConfig.BirthdayAnnounceChannel) as TestChannel; + var channel = await orchestrator.Discord.GetChannel(orchestrator.Configuration.BirthdayConfig.BirthdayAnnounceChannel) as TestChannel; channel.Should().NotBeNull(); var messages = await channel.GetMessagesAsync().SelectMany(n => n).ToListAsync(); messages.Count.Should().BeGreaterThan(0); TestUtilities.MatchesFormat(Strings.Birthday_Announcement, messages[0].Content); - - ctx.Metrics.GetMetricValues(Metric.BirthdaySystem_Failures).Sum().Should().Be(0); - ctx.Metrics.GetMetricValues(Metric.BirthdaySystem_Grants).Sum().Should().Be(1); + + orchestrator.Metrics.GetMetricValues(Metric.BirthdaySystem_Failures).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.BirthdaySystem_Grants).Sum().Should().Be(1); } [Fact] @@ -140,30 +84,27 @@ public static async Task BirthdaySystem_WhenUserBirthday_ShouldUpdateAgeRoles() var birthdate = DateTime.Parse("2000-02-14T00:00:00Z"); var today = DateTime.Parse("2016-02-14T00:00:00Z"); - var ctx = await Setup(today, birthdate); + var orchestrator = await SetupOrchestrator(today, birthdate); + var system = orchestrator.GetService(); - int yearsOld = ctx.Birthday.Age; + int yearsOld = new Birthday(birthdate, orchestrator.TimeProvider).Age; - var priorAgeSnowflake = ctx.Cfg.BirthdayConfig.AgeRoleMap.First(n => n.Age == yearsOld - 1).Role; - var newAgeSnowflake = ctx.Cfg.BirthdayConfig.AgeRoleMap.First(n => n.Age == yearsOld).Role; + var priorAgeSnowflake = orchestrator.Configuration.BirthdayConfig.AgeRoleMap.First(n => n.Age == yearsOld - 1).Role; + var newAgeSnowflake = orchestrator.Configuration.BirthdayConfig.AgeRoleMap.First(n => n.Age == yearsOld).Role; // Preassert - var user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().Contain(priorAgeSnowflake); - user.RoleIds.Should().NotContain(newAgeSnowflake); + orchestrator.Subject.RoleIds.Should().Contain(priorAgeSnowflake); + orchestrator.Subject.RoleIds.Should().NotContain(newAgeSnowflake); // Act - await ctx.System.RunAsync(); + await system.RunAsync(); // Assert // The main thing we're looking for in this test is whether the previous age // role was removed and the new one applied. - user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().NotContain(priorAgeSnowflake); - user.RoleIds.Should().Contain(newAgeSnowflake); + orchestrator.Subject.RoleIds.Should().NotContain(priorAgeSnowflake); + orchestrator.Subject.RoleIds.Should().Contain(newAgeSnowflake); } [Fact] @@ -173,30 +114,25 @@ public static async Task BirthdaySystem_WhenUserBirthdayWithNoYear_ShouldNotUpda var birthday = DateTime.Parse("1600-02-14T00:00:00Z"); var today = DateTime.Parse("2016-02-14T00:00:00Z"); - var ctx = await Setup(today, birthday, roleUpdateFn: (_, cfg) => - { - // just return the 16 age role - return [ cfg.BirthdayConfig.AgeRoleMap.First(n => n.Age == 16).Role.ID ]; - }); + var orchestrator = await SetupOrchestrator(today, birthday); + await orchestrator.Subject.RemoveRolesAsync(orchestrator.Subject.RoleIds); + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.BirthdayConfig.AgeRoleMap.First(n => n.Age == 16).Role.ID); - // Preassert - var user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); + var system = orchestrator.GetService(); - user.RoleIds.Should().ContainSingle(); - var priorRoleId = user.RoleIds.First(); + // Preassert + orchestrator.Subject.RoleIds.Should().ContainSingle(); + var priorRoleId = orchestrator.Subject.RoleIds.First(); // Act - await ctx.System.RunAsync(); + await system.RunAsync(); // Assert - user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); // Since the user's birth year isn't set, we can't actually calculate their age, // so we expect that their age roles won't be changed. - user.RoleIds.Should().HaveCount(2); - user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); - user.RoleIds.Should().Contain(priorRoleId); + orchestrator.Subject.RoleIds.Should().HaveCount(2); + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().Contain(priorRoleId); } [Fact] @@ -206,17 +142,16 @@ public static async Task BirthdaySystem_WhenNoBirthdays_ShouldDoNothing() var birthday = DateTime.Parse("2000-02-14T00:00:00Z"); var today = DateTime.Parse("2025-02-17T00:00:00Z"); - var ctx = await Setup(today, birthday); + var orchestrator = await SetupOrchestrator(today, birthday); + var system = orchestrator.GetService(); // Act - await ctx.System.RunAsync(); + await system.RunAsync(); // Assert - var user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().NotContain(ctx.Cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().NotContain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); - var channel = await ctx.Discord.GetChannel(ctx.Cfg.BirthdayConfig.BirthdayAnnounceChannel) as TestChannel; + var channel = await orchestrator.Discord.GetChannel(orchestrator.Configuration.BirthdayConfig.BirthdayAnnounceChannel) as TestChannel; channel.Should().NotBeNull(); var messages = await channel.GetMessagesAsync().SelectMany(n => n).ToListAsync(); @@ -230,21 +165,21 @@ public static async Task BirthdaySystem_WithUserHavingOldBirthday_ShouldRemoveOl var birthday = DateTime.Parse("2000-02-13T00:00:00Z"); var today = DateTime.Parse("2025-02-14T00:00:00Z"); - var ctx = await Setup(today, birthday, true); + var orchestrator = await SetupOrchestrator(today, birthday); + var system = orchestrator.GetService(); + + // Add the birthday role + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.BirthdayConfig.BirthdayRole); // Pre assert - var user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); // Act - await ctx.System.RunAsync(); + await system.RunAsync(); // Assert - user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().NotContain(ctx.Cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().NotContain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); } [Fact] @@ -254,21 +189,22 @@ public static async Task BirthdaySystem_WithBirthdayRoleButNoBirthdayRecord_Shou var birthday = DateTime.Parse("2000-02-13T00:00:00Z"); var today = DateTime.Parse("2025-02-14T00:00:00Z"); - var ctx = await Setup(today, applyBirthday: true); + var orchestrator = await SetupOrchestrator(today, birthday); + var system = orchestrator.GetService(); + + // Add the birthday role + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.BirthdayConfig.BirthdayRole); + // Pre assert - var user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); // Act - await ctx.System.RunAsync(); + await system.RunAsync(); // Assert - user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().NotContain(ctx.Cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().NotContain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); } [Fact] @@ -278,29 +214,19 @@ public static async Task BirthdaySystem_WithUserBirthdayStill_ShouldKeepBirthday var birthday = DateTime.Parse("2000-02-13T12:00:00Z"); var today = DateTime.Parse("2025-02-14T00:00:00Z"); - var ctx = await Setup(today, birthday, true); + var orchestrator = await SetupOrchestrator(today, birthday); + var system = orchestrator.GetService(); + + // Add the birthday role + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.BirthdayConfig.BirthdayRole); // Pre assert - var user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); // Act - await ctx.System.RunAsync(); + await system.RunAsync(); // Assert - user = ctx.Discord.GetUser(ctx.TestUserId); - user.Should().NotBeNull(); - user.RoleIds.Should().Contain(ctx.Cfg.BirthdayConfig.BirthdayRole); + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); } - - private record Context( - Snowflake TestUserId, - BirthdaySystem System, - MockInstarDDBService DDB, - MockDiscordService Discord, - MockMetricService Metrics, - InstarDynamicConfiguration Cfg, - Birthday? Birthday - ); } \ No newline at end of file diff --git a/InstarBot.Tests.Integration/TestTest.cs b/InstarBot.Tests.Integration/TestTest.cs new file mode 100644 index 0000000..73c571a --- /dev/null +++ b/InstarBot.Tests.Integration/TestTest.cs @@ -0,0 +1,49 @@ +using Discord; +using FluentAssertions; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.Services; +using Xunit; + +namespace InstarBot.Tests.Integration; + +public class TestTest +{ + private const ulong NewMemberRole = 796052052433698817ul; + + private static async Task<(TestOrchestrator, Mock)> SetupMocks2(DateTimeOffset? timeOverride = null, bool throwError = false) + { + var orchestrator = await TestOrchestrator.Builder + .WithTime(timeOverride) + .WithService() + .Build(); + + await orchestrator.Actor.AddRoleAsync(NewMemberRole); + + // yoooo, this is going to be so nice if this all works + var cmd = orchestrator.GetCommand(); + + if (throwError) + { + if (orchestrator.GetService() is not IMockOf ddbService) + throw new InvalidOperationException("IDatabaseService was not mocked correctly."); + + ddbService.Mock.Setup(n => n.GetOrCreateUserAsync(It.IsAny())).Throws(); + } + + return (orchestrator, cmd); + } + + [Fact] + public async Task Test() + { + var (orchestrator, mock) = await SetupMocks2(DateTimeOffset.UtcNow); + + var guildUser = orchestrator.Actor as IGuildUser; + + guildUser.RoleIds.Should().Contain(NewMemberRole); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/IMockOf.cs b/InstarBot.Tests.Orchestrator/IMockOf.cs new file mode 100644 index 0000000..57fb0b4 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/IMockOf.cs @@ -0,0 +1,15 @@ +using Moq; + +namespace InstarBot.Test.Framework; + +/// +/// Represents an object that provides access to the underlying mock for a specified type. +/// +/// This interface is typically used to expose the mock instance associated with a particular type, +/// enabling advanced configuration or verification in unit tests. It is commonly implemented by test doubles or helper +/// classes that encapsulate a mock object. +/// The type of the object being mocked. Must be a reference type. +public interface IMockOf where T : class +{ + Mock Mock { get; } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj b/InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj new file mode 100644 index 0000000..76bb766 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/InstarBot.Tests.Orchestrator/MockExtensions.cs b/InstarBot.Tests.Orchestrator/MockExtensions.cs new file mode 100644 index 0000000..d29e7fe --- /dev/null +++ b/InstarBot.Tests.Orchestrator/MockExtensions.cs @@ -0,0 +1,100 @@ +using Discord; +using InstarBot.Tests; +using Moq; +using Moq.Protected; +using PaxAndromeda.Instar.Commands; + +namespace InstarBot.Test.Framework; + +public static class MockExtensions +{ + extension(Mock channel) where T : class, IMessageChannel + { + /// + /// Verifies that the command responded to the user with the correct . + /// + /// The string format to check called messages against. + /// A flag indicating whether partial matches are acceptable. + public void VerifyMessage(string format, bool partial = false) + { + channel.Verify(c => c.SendMessageAsync( + It.Is(s => TestUtilities.MatchesFormat(s, format, partial)), + false, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )); + } + } + + extension(Mock channel) where T : class, ITextChannel + { + /// + /// Verifies that the command responded to the user with an embed that satisfies the specified . + /// + /// The type of command. Must implement . + /// An instance to verify against. + /// An optional message format, if present. Defaults to null. + /// An optional flag indicating whether partial matches are acceptable. Defaults to false. + public void VerifyMessageEmbed(EmbedVerifier verifier, string format, bool partial = false) + { + channel.Verify(c => c.SendMessageAsync( + It.Is(n => TestUtilities.MatchesFormat(n, format, partial)), + false, + It.Is(e => verifier.Verify(e)), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )); + } + } + + extension(Mock command) where T : BaseCommand + { + public void VerifyResponse(string format, bool ephemeral = false, bool partial = false) + => command.VerifyResponseAndEmbed(format, null, ephemeral, partial); + + public void VerifyResponse(EmbedVerifier embedVerifier, bool ephemeral = false, bool partial = false) + => command.VerifyResponseAndEmbed(null, embedVerifier, ephemeral, partial); + + public void VerifyResponse(string format, EmbedVerifier embedVerifier, bool ephemeral = false, bool partial = false) + => command.VerifyResponseAndEmbed(format, embedVerifier, ephemeral, partial); + + private void VerifyResponseAndEmbed(string? format = null, EmbedVerifier? embedVerifier = null, bool ephemeral = false, bool partial = false) + { + var msgRef = format is null + ? ItExpr.IsNull() + : ItExpr.Is(n => TestUtilities.MatchesFormat(n, format, partial)); + + var embedRef = embedVerifier is null + ? ItExpr.IsAny() + : ItExpr.Is(e => embedVerifier.Verify(e)); + + command.Protected().Verify( + "RespondAsync", + Times.Once(), + msgRef, // text + ItExpr.IsAny(), // embeds + false, // isTTS + ephemeral, // ephemeral + ItExpr.IsAny(), // allowedMentions + ItExpr.IsAny(), // options + ItExpr.IsAny(), // components + embedRef, // embed + ItExpr.IsAny(), // pollProperties + ItExpr.IsAny() // messageFlags + ); + } + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Models/TestChannel.cs b/InstarBot.Tests.Orchestrator/Models/TestChannel.cs new file mode 100644 index 0000000..d5edfc0 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Models/TestChannel.cs @@ -0,0 +1,339 @@ +using Discord; +using InstarBot.Tests; +using Moq; +using PaxAndromeda.Instar; +using System.Diagnostics.CodeAnalysis; +using InvalidOperationException = Amazon.CloudWatchLogs.Model.InvalidOperationException; +using MessageProperties = Discord.MessageProperties; + +#pragma warning disable CS1998 +#pragma warning disable CS8625 + +namespace InstarBot.Test.Framework.Models; + +[SuppressMessage("ReSharper", "ReplaceAutoPropertyWithComputedProperty")] +public sealed class TestChannel : IMockOf, ITextChannel +{ + public ulong Id { get; } + public DateTimeOffset CreatedAt { get; } + + public Mock Mock { get; } = new(); + + private readonly Dictionary _messages = new(); + + public IEnumerable Messages => _messages.Values; + + public TestChannel(Snowflake id) + { + Id = id; + CreatedAt = id.Time; + } + + public void VerifyMessage(string format, bool partial = false) + { + Mock.Verify(c => c.SendMessageAsync( + It.Is(s => TestUtilities.MatchesFormat(s, format, partial)), + false, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )); + } + + public void VerifyEmbed(EmbedVerifier verifier, string format, bool partial = false) + { + Mock.Verify(c => c.SendMessageAsync( + It.Is(n => TestUtilities.MatchesFormat(n, format, partial)), + false, + It.Is(e => verifier.Verify(e)), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )); + } + + + public Task GetMessageAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + { + return Mock.Object.GetMessageAsync(id, mode, options); + } + + public async IAsyncEnumerable> GetMessagesAsync(int limit = 100, + CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + { + yield return _messages.Values.ToList().AsReadOnly(); + } + + public async IAsyncEnumerable> GetMessagesAsync(ulong fromMessageId, Direction dir, + int limit = 100, CacheMode mode = CacheMode.AllowDownload, + RequestOptions options = null) + { + var snowflake = new Snowflake(fromMessageId); + var rz = _messages.Values.Where(n => n.Timestamp.UtcDateTime > snowflake.Time.ToUniversalTime()).ToList().AsReadOnly(); + + yield return rz; + } + + public IAsyncEnumerable> GetMessagesAsync(IMessage fromMessage, Direction dir, + int limit = 100, CacheMode mode = CacheMode.AllowDownload, + RequestOptions options = null) + => GetMessagesAsync(fromMessage.Id, dir, limit, mode, options); + + public Task> GetPinnedMessagesAsync(RequestOptions options = null) + { + return Mock.Object.GetPinnedMessagesAsync(options); + } + + public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) + { + return Mock.Object.DeleteMessageAsync(messageId, options); + } + + public Task DeleteMessageAsync(IMessage message, RequestOptions options = null) + { + return Mock.Object.DeleteMessageAsync(message, options); + } + + public Task ModifyMessageAsync(ulong messageId, Action func, RequestOptions options = null) + { + return Mock.Object.ModifyMessageAsync(messageId, func, options); + } + + public Task TriggerTypingAsync(RequestOptions options = null) + { + return Mock.Object.TriggerTypingAsync(options); + } + + public IDisposable EnterTypingState(RequestOptions options = null) + { + return Mock.Object.EnterTypingState(options); + } + + public string Mention { get; } = null!; + public Task GetCategoryAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + { + return Mock.Object.GetCategoryAsync(mode, options); + } + + public Task SyncPermissionsAsync(RequestOptions options = null) + { + return Mock.Object.SyncPermissionsAsync(options); + } + + public Task CreateInviteAsync(int? maxAge, int? maxUses = null, bool isTemporary = false, bool isUnique = false, + RequestOptions options = null) + { + return Mock.Object.CreateInviteAsync(maxAge, maxUses, isTemporary, isUnique, options); + } + + public Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge, int? maxUses = null, bool isTemporary = false, + bool isUnique = false, RequestOptions options = null) + { + return Mock.Object.CreateInviteToApplicationAsync(applicationId, maxAge, maxUses, isTemporary, isUnique, options); + } + + public Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge, int? maxUses = null, + bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + { + return Mock.Object.CreateInviteToApplicationAsync(application, maxAge, maxUses, isTemporary, isUnique, options); + } + + public Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = null, bool isTemporary = false, + bool isUnique = false, RequestOptions options = null) + { + return Mock.Object.CreateInviteToStreamAsync(user, maxAge, maxUses, isTemporary, isUnique, options); + } + + public Task> GetInvitesAsync(RequestOptions options = null) + { + return Mock.Object.GetInvitesAsync(options); + } + + public ulong? CategoryId { get; } = null; + public Task DeleteMessagesAsync(IEnumerable messages, RequestOptions options = null) + { + return Mock.Object.DeleteMessagesAsync(messages, options); + } + + public Task DeleteMessagesAsync(IEnumerable messageIds, RequestOptions options = null) + { + return Mock.Object.DeleteMessagesAsync(messageIds, options); + } + + public Task ModifyAsync(Action func, RequestOptions options = null) + { + return Mock.Object.ModifyAsync(func, options); + } + + public Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, + IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null) + { + return Mock.Object.CreateThreadAsync(name, type, autoArchiveDuration, message, invitable, slowmode, options); + } + + public Task> GetActiveThreadsAsync(RequestOptions options = null) + { + return Mock.Object.GetActiveThreadsAsync(options); + } + + public bool IsNsfw { get; } = false; + public string Topic { get; } = null!; + public int SlowModeInterval { get; } = 0; + public int DefaultSlowModeInterval => Mock.Object.DefaultSlowModeInterval; + + public ThreadArchiveDuration DefaultArchiveDuration { get; } = default!; + + public TestMessage AddMessage(IGuildUser user, string messageContent) + { + var message = new TestMessage(user, messageContent) + { + Channel = this, + }; + + _messages.Add(message.Id, message); + + return message; + } + + public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + Mock.Object.SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags, poll); + + var msg = new TestMessage(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags, poll); + _messages.Add(msg.Id, msg); + + return Task.FromResult(msg); + } + + public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + return Mock.Object.SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, components, stickers, embeds, flags, poll); + } + + public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None, PollProperties poll = null) + { + return Mock.Object.SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, components, stickers, embeds, flags, poll); + } + + public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, + PollProperties poll = null) + { + return Mock.Object.SendFileAsync(attachment, text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags, poll); + } + + public Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, + PollProperties poll = null) + { + return Mock.Object.SendFilesAsync(attachments, text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags, poll); + } + + public Task ModifyAsync(Action func, RequestOptions options = null) + { + return ((IGuildChannel)Mock).ModifyAsync(func, options); + } + + public OverwritePermissions? GetPermissionOverwrite(IRole role) + { + return Mock.Object.GetPermissionOverwrite(role); + } + + public OverwritePermissions? GetPermissionOverwrite(IUser user) + { + return Mock.Object.GetPermissionOverwrite(user); + } + + public Task RemovePermissionOverwriteAsync(IRole role, RequestOptions options = null) + { + return Mock.Object.RemovePermissionOverwriteAsync(role, options); + } + + public Task RemovePermissionOverwriteAsync(IUser user, RequestOptions options = null) + { + return Mock.Object.RemovePermissionOverwriteAsync(user, options); + } + + public Task AddPermissionOverwriteAsync(IRole role, OverwritePermissions permissions, RequestOptions options = null) + { + return Mock.Object.AddPermissionOverwriteAsync(role, permissions, options); + } + + public Task AddPermissionOverwriteAsync(IUser user, OverwritePermissions permissions, RequestOptions options = null) + { + return Mock.Object.AddPermissionOverwriteAsync(user, permissions, options); + } + + public IAsyncEnumerable> GetUsersAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + { + return Mock.Object.GetUsersAsync(mode, options); + } + + Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode , RequestOptions options ) + { + return Mock.Object.GetUserAsync(id, mode, options); + } + + public int Position => Mock.Object.Position; + + public ChannelFlags Flags => Mock.Object.Flags; + + public IGuild Guild => Mock.Object.Guild; + + public ulong GuildId => Mock.Object.GuildId; + + public IReadOnlyCollection PermissionOverwrites => Mock.Object.PermissionOverwrites; + + IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode , RequestOptions options ) + { + return ((IChannel)Mock).GetUsersAsync(mode, options); + } + + Task IChannel.GetUserAsync(ulong id, CacheMode mode , RequestOptions options ) + { + return ((IChannel)Mock).GetUserAsync(id, mode, options); + } + + public ChannelType ChannelType => Mock.Object.ChannelType; + + public string Name => Mock.Name; + + public Task DeleteAsync(RequestOptions options = null) + { + return Mock.Object.DeleteAsync(options); + } + + public Task CreateWebhookAsync(string name, Stream avatar = null, RequestOptions options = null) + { + return Mock.Object.CreateWebhookAsync(name, avatar, options); + } + + public Task GetWebhookAsync(ulong id, RequestOptions options = null) + { + return Mock.Object.GetWebhookAsync(id, options); + } + + public Task> GetWebhooksAsync(RequestOptions options = null) + { + return Mock.Object.GetWebhooksAsync(options); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Models/TestGuild.cs b/InstarBot.Tests.Orchestrator/Models/TestGuild.cs new file mode 100644 index 0000000..54edafd --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Models/TestGuild.cs @@ -0,0 +1,42 @@ +using Discord; +using PaxAndromeda.Instar; + +namespace InstarBot.Test.Framework.Models; + +// ReSharper disable once ClassWithVirtualMembersNeverInherited.Global +public class TestGuild : IInstarGuild +{ + public ulong Id { get; init; } + private readonly List _channels = []; + + public IEnumerable TextChannels + { + get => _channels; + init => _channels = value.OfType().ToList(); + } + + + public Dictionary Roles { get; init; } = null!; + + public List Users { get; init; } = []; + + public virtual ITextChannel GetTextChannel(ulong channelId) + { + return TextChannels.First(n => n.Id.Equals(channelId)); + } + + public virtual IRole? GetRole(Snowflake roleId) + { + return Roles.GetValueOrDefault(roleId); + } + + public void AddUser(TestGuildUser user) + { + Users.Add(user); + } + + public void AddChannel(TestChannel testChannel) + { + _channels.Add(testChannel); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestGuildUser.cs b/InstarBot.Tests.Orchestrator/Models/TestGuildUser.cs similarity index 53% rename from InstarBot.Tests.Common/Models/TestGuildUser.cs rename to InstarBot.Tests.Orchestrator/Models/TestGuildUser.cs index fea45a8..6553d12 100644 --- a/InstarBot.Tests.Common/Models/TestGuildUser.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestGuildUser.cs @@ -1,31 +1,32 @@ using System.Diagnostics.CodeAnalysis; using Discord; +using InstarBot.Tests; using Moq; +using PaxAndromeda.Instar; #pragma warning disable CS8625 -namespace InstarBot.Tests.Models; +namespace InstarBot.Test.Framework.Models; [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] [SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] -public class TestGuildUser : IGuildUser +public class TestGuildUser : TestUser, IMockOf, IGuildUser { - private readonly List _roleIds = [ ]; - - public ulong Id { get; init; } - public DateTimeOffset CreatedAt { get; set; } - public string Mention { get; set; } = null!; - public UserStatus Status { get; set; } - public IReadOnlyCollection ActiveClients { get; set; } = null!; - public IReadOnlyCollection Activities { get; set; } = null!; - public string AvatarId { get; set; } = null!; - public string Discriminator { get; set; } = null!; - public ushort DiscriminatorValue { get; set; } - public bool IsBot { get; set; } - public bool IsWebhook { get; set; } - public string Username { get; set; } = null!; - public UserProperties? PublicFlags { get; set; } - public bool IsDeafened { get; set; } + public Mock Mock { get; } = new(); + + private HashSet _roleIds = [ ]; + + public TestGuildUser() : this(Snowflake.Generate(), [ ]) { } + + public TestGuildUser(Snowflake snowflake) : this(snowflake, [ ]) + { } + + public TestGuildUser(Snowflake snowflake, IEnumerable roles) : base(snowflake) + { + _roleIds = roles.Select(n => n.ID).ToHashSet(); + } + + public bool IsDeafened { get; set; } public bool IsMuted { get; set; } public bool IsSelfDeafened { get; set; } public bool IsSelfMuted { get; set; } @@ -36,49 +37,32 @@ public class TestGuildUser : IGuildUser public bool IsVideoing { get; set; } public DateTimeOffset? RequestToSpeakTimestamp { get; set; } - private readonly Mock _dmChannelMock = new(); - - public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) - { - return string.Empty; - } - - public string GetDefaultAvatarUrl() - { - return string.Empty; - } - - public Task CreateDMChannelAsync(RequestOptions options = null!) - { - return Task.FromResult(_dmChannelMock.Object); - } - - public ChannelPermissions GetPermissions(IGuildChannel channel) - { - throw new NotImplementedException(); - } + public ChannelPermissions GetPermissions(IGuildChannel channel) + { + return Mock.Object.GetPermissions(channel); + } - public string GetGuildAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) - { - throw new NotImplementedException(); - } + public string GetGuildAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + { + return Mock.Object.GetGuildAvatarUrl(format, size); + } - public string GetDisplayAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) - { - throw new NotImplementedException(); - } + public string GetGuildBannerUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + { + return Mock.Object.GetGuildBannerUrl(format, size); + } - public Task KickAsync(string reason = null, RequestOptions options = null!) - { - throw new NotImplementedException(); - } + public Task KickAsync(string reason = null, RequestOptions options = null) + { + return Mock.Object.KickAsync(reason, options); + } - public Task ModifyAsync(Action func, RequestOptions options = null) - { - throw new NotImplementedException(); - } + public Task ModifyAsync(Action func, RequestOptions options = null) + { + return Mock.Object.ModifyAsync(func, options); + } - public Task AddRoleAsync(ulong roleId, RequestOptions options = null) + public Task AddRoleAsync(ulong roleId, RequestOptions options = null) { Changed = true; _roleIds.Add(roleId); @@ -95,14 +79,18 @@ public Task AddRoleAsync(IRole role, RequestOptions options = null) public Task AddRolesAsync(IEnumerable roleIds, RequestOptions options = null) { Changed = true; - _roleIds.AddRange(roleIds); + foreach (var id in roleIds) + _roleIds.Add(id); + return Task.CompletedTask; } public Task AddRolesAsync(IEnumerable roles, RequestOptions options = null) { Changed = true; - _roleIds.AddRange(roles.Select(role => role.Id)); + foreach (var role in roles) + _roleIds.Add(role.Id); + return Task.CompletedTask; } @@ -137,22 +125,12 @@ public Task RemoveRolesAsync(IEnumerable roles, RequestOptions options = public Task SetTimeOutAsync(TimeSpan span, RequestOptions options = null) { - throw new NotImplementedException(); + return Mock.Object.SetTimeOutAsync(span, options); } public Task RemoveTimeOutAsync(RequestOptions options = null) { - throw new NotImplementedException(); - } - - public string GetGuildBannerUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) - { - throw new NotImplementedException(); - } - - public string GetAvatarDecorationUrl() - { - throw new NotImplementedException(); + return Mock.Object.RemoveTimeOutAsync(options); } public DateTimeOffset? JoinedAt { get; init; } @@ -162,14 +140,14 @@ public string GetAvatarDecorationUrl() public string GuildAvatarId { get; set; } = null!; public GuildPermissions GuildPermissions { get; set; } public IGuild Guild { get; set; } = null!; - public ulong GuildId => TestUtilities.GuildID; + public ulong GuildId { get; internal set; } = 0; public DateTimeOffset? PremiumSince { get; set; } public IReadOnlyCollection RoleIds - { - get => _roleIds.AsReadOnly(); - init => _roleIds = value.ToList(); - } + { + get => _roleIds.AsReadOnly(); + set => _roleIds = new HashSet(value); + } public bool? IsPending { get; set; } public int Hierarchy { get; set; } @@ -183,15 +161,13 @@ public IReadOnlyCollection RoleIds public string GuildBannerHash { get; set; } = null!; - public string GlobalName { get; set; } = null!; - - public string AvatarDecorationHash { get; set; } = null!; - - public ulong? AvatarDecorationSkuId { get; set; } = null!; - public PrimaryGuild? PrimaryGuild { get; set; } = null!; - - public TestGuildUser Clone() - { + public TestGuildUser Clone() + { return (TestGuildUser) MemberwiseClone(); - } + } + + public void Reset() + { + Changed = false; + } } \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs b/InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs new file mode 100644 index 0000000..1481f5a --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs @@ -0,0 +1,71 @@ +using Discord; +using FluentAssertions; +using Moq; +using PaxAndromeda.Instar; +using System; +using System.Threading; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Test.Framework.Models; + +public class TestInteractionContext : InstarContext, IMockOf +{ + private readonly TestOrchestrator _orchestrator; + public Mock Mock { get; } = new(); + + public IDiscordClient Client => Mock.Object.Client; + + public TestInteractionContext(TestOrchestrator orchestrator, Snowflake actorId, Snowflake channelId) + { + _orchestrator = orchestrator; + + var discordService = orchestrator.GetService(); + if (discordService.GetUser(actorId) is not { } actor) + throw new InvalidOperationException("Actor needs to be registered before creating an interaction context"); + + + Mock.SetupGet(static n => n.User!) + .Returns(orchestrator.GetService().GetUser(actorId) + ?? throw new InvalidOperationException("Failed to mock interaction context correctly: missing actor")); + + Mock.SetupGet(static n => n.Channel!) + .Returns(orchestrator.GetService().GetChannel(channelId).Result as TestChannel + ?? throw new InvalidOperationException("Failed to mock interaction context correctly: missing channel")); + + Mock.SetupGet(static n => n.Guild).Returns(orchestrator.GetService().GetGuild()); + } + + private Mock SetupGuildMock() + { + var discordService = _orchestrator.GetService(); + + var guildMock = new Mock(); + guildMock.Setup(n => n.Id).Returns(_orchestrator.GuildID); + guildMock.Setup(n => n.GetTextChannel(It.IsAny())) + .Returns((ulong x) => discordService.GetChannel(x) as ITextChannel); + + return guildMock; + } + + private TestGuildUser SetupUser(IGuildUser user) + { + return new TestGuildUser(user.Id, user.RoleIds.Select(id => new Snowflake(id))); + } + + private TestChannel SetupChannel(Snowflake channelId) + { + var discordService = _orchestrator.GetService(); + var channel = discordService.GetChannel(channelId).Result; + + if (channel is not TestChannel testChannel) + throw new InvalidOperationException("Channel must be registered before use in an interaction context"); + + return testChannel; + } + + + protected internal override IInstarGuild Guild => Mock.Object.Guild; + protected internal override IGuildChannel Channel => Mock.Object.Channel; + protected internal override IGuildUser User => Mock.Object.User; + public IDiscordInteraction Interaction { get; } = new Mock().Object; +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Models/TestMessage.cs b/InstarBot.Tests.Orchestrator/Models/TestMessage.cs new file mode 100644 index 0000000..71c82ed --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Models/TestMessage.cs @@ -0,0 +1,155 @@ +using Discord; +using Moq; +using PaxAndromeda.Instar; +using MessageProperties = Discord.MessageProperties; + +namespace InstarBot.Test.Framework.Models; + +public sealed class TestMessage : IMockOf, IUserMessage, IMessage +{ + public Mock Mock { get; } = new(); + + internal TestMessage(IUser user, string message) + { + Id = Snowflake.Generate(); + CreatedAt = DateTimeOffset.Now; + Timestamp = DateTimeOffset.Now; + Author = user; + + Content = message; + } + + public TestMessage(string text, bool isTTS, Embed? embed, RequestOptions? options, AllowedMentions? allowedMentions, MessageReference? messageReference, MessageComponent? components, ISticker[]? stickers, Embed[]? embeds, MessageFlags? flags, PollProperties? poll) + { + Id = Snowflake.Generate(); + Content = text; + IsTTS = isTTS; + Flags = flags; + + var embedList = new List(); + + if (embed is not null) + embedList.Add(embed); + if (embeds is not null) + embedList.AddRange(embeds); + + Flags = flags; + Reference = messageReference; + } + + public ulong Id { get; } + public DateTimeOffset CreatedAt { get; } + + public Task AddReactionAsync(IEmote emote, RequestOptions? options = null) + { + return Mock.Object.AddReactionAsync(emote, options); + } + + public Task RemoveReactionAsync(IEmote emote, IUser user, RequestOptions? options = null) + { + return Mock.Object.RemoveReactionAsync(emote, user, options); + } + + public Task RemoveReactionAsync(IEmote emote, ulong userId, RequestOptions? options = null) + { + return Mock.Object.RemoveReactionAsync(emote, userId, options); + } + + public Task RemoveAllReactionsAsync(RequestOptions? options = null) + { + return Mock.Object.RemoveAllReactionsAsync(options); + } + + public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions? options = null) + { + return Mock.Object.RemoveAllReactionsForEmoteAsync(emote, options); + } + + public IAsyncEnumerable> GetReactionUsersAsync(IEmote emoji, int limit, RequestOptions? options = null, + ReactionType type = ReactionType.Normal) + { + return Mock.Object.GetReactionUsersAsync(emoji, limit, options, type); + } + + public MessageType Type => default; + public MessageSource Source => default; + public bool IsTTS { get; set; } + + public bool IsPinned => false; + public bool IsSuppressed => false; + public bool MentionedEveryone => false; + public string Content { get; } + public string CleanContent => null!; + public DateTimeOffset Timestamp { get; } + public DateTimeOffset? EditedTimestamp => null; + public IMessageChannel Channel { get; set; } = null!; + public IUser Author { get; } + public IThreadChannel Thread => null!; + public IReadOnlyCollection Attachments => null!; + public IReadOnlyCollection Embeds => null!; + public IReadOnlyCollection Tags => null!; + public IReadOnlyCollection MentionedChannelIds => null!; + public IReadOnlyCollection MentionedRoleIds => null!; + public IReadOnlyCollection MentionedUserIds => null!; + public MessageActivity Activity => null!; + public MessageApplication Application => null!; + public MessageReference Reference { get; set; } + + public IReadOnlyDictionary Reactions => null!; + public IReadOnlyCollection Components => null!; + public IReadOnlyCollection Stickers => null!; + public MessageFlags? Flags { get; set; } + + public IMessageInteraction Interaction => null!; + public MessageRoleSubscriptionData RoleSubscriptionData => null!; + public PurchaseNotification PurchaseNotification => Mock.Object.PurchaseNotification; + + public MessageCallData? CallData => Mock.Object.CallData; + + public Task ModifyAsync(Action func, RequestOptions? options = null) + { + return Mock.Object.ModifyAsync(func, options); + } + + public Task PinAsync(RequestOptions? options = null) + { + return Mock.Object.PinAsync(options); + } + + public Task UnpinAsync(RequestOptions? options = null) + { + return Mock.Object.UnpinAsync(options); + } + + public Task CrosspostAsync(RequestOptions? options = null) + { + return Mock.Object.CrosspostAsync(options); + } + + public string Resolve(TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name, TagHandling roleHandling = TagHandling.Name, + TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name) + { + return Mock.Object.Resolve(userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling); + } + + public Task EndPollAsync(RequestOptions options) + { + return Mock.Object.EndPollAsync(options); + } + + public IAsyncEnumerable> GetPollAnswerVotersAsync(uint answerId, int? limit = null, ulong? afterId = null, + RequestOptions? options = null) + { + return Mock.Object.GetPollAnswerVotersAsync(answerId, limit, afterId, options); + } + + public MessageResolvedData ResolvedData { get; set; } + public IUserMessage ReferencedMessage { get; set; } + public IMessageInteractionMetadata InteractionMetadata { get; set; } + public IReadOnlyCollection ForwardedMessages { get; set; } + public Poll? Poll { get; set; } + public Task DeleteAsync(RequestOptions? options = null) + { + return Mock.Object.DeleteAsync(options); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestRole.cs b/InstarBot.Tests.Orchestrator/Models/TestRole.cs similarity index 63% rename from InstarBot.Tests.Common/Models/TestRole.cs rename to InstarBot.Tests.Orchestrator/Models/TestRole.cs index ae71435..e9f8eed 100644 --- a/InstarBot.Tests.Common/Models/TestRole.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestRole.cs @@ -1,14 +1,17 @@ using System.Diagnostics.CodeAnalysis; using Discord; +using Moq; using PaxAndromeda.Instar; #pragma warning disable CS8625 -namespace InstarBot.Tests.Models; +namespace InstarBot.Test.Framework.Models; [SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] -public sealed class TestRole : IRole +public sealed class TestRole : IMockOf, IRole { + public Mock Mock { get; } = new(); + internal TestRole(Snowflake snowflake) { Id = snowflake; @@ -18,29 +21,20 @@ internal TestRole(Snowflake snowflake) public ulong Id { get; set; } public DateTimeOffset CreatedAt { get; set; } - public Task DeleteAsync(RequestOptions options = null) - { - throw new NotImplementedException(); - } - public string Mention { get; set; } = null!; - public int CompareTo(IRole? other) + public int CompareTo(IRole? other) { - throw new NotImplementedException(); - } + return Comparer.Default.Compare(this, other); + } - public Task ModifyAsync(Action func, RequestOptions options = null) - { - throw new NotImplementedException(); - } + public Task DeleteAsync(RequestOptions options = null) => Mock.Object.DeleteAsync(options); - public string GetIconUrl() - { - throw new NotImplementedException(); - } + public Task ModifyAsync(Action func, RequestOptions options = null) => Mock.Object.ModifyAsync(func, options); + + public string GetIconUrl() => Mock.Object.GetIconUrl(); - public IGuild Guild { get; set; } = null!; + public IGuild Guild { get; set; } = null!; public Color Color { get; set; } = default!; public bool IsHoisted { get; set; } = false; public bool IsManaged { get; set; } = false; diff --git a/InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs b/InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs new file mode 100644 index 0000000..377aa2f --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs @@ -0,0 +1,84 @@ +using Discord; +using Discord.WebSocket; +using Moq; + +namespace InstarBot.Test.Framework.Models; + +public class TestSocketUser : IMockOf +{ + public Mock Mock { get; } = new(); + + + public bool IsBot + { + get => Mock.Object.IsBot; + set => Mock.Setup(obj => obj.IsBot).Returns(value); + } + + public string Username + { + get => Mock.Object.Username; + set => Mock.Setup(obj => obj.Username).Returns(value); + } + + public ushort DiscriminatorValue + { + get => Mock.Object.DiscriminatorValue; + set => Mock.Setup(obj => obj.DiscriminatorValue).Returns(value); + } + + public string AvatarId + { + get => Mock.Object.AvatarId; + set => Mock.Setup(obj => obj.AvatarId).Returns(value); + } + + public bool IsWebhook + { + get => Mock.Object.IsWebhook; + set => Mock.Setup(obj => obj.IsWebhook).Returns(value); + } + + public string GlobalName + { + get => Mock.Object.GlobalName; + set => Mock.Setup(obj => obj.GlobalName).Returns(value); + } + + public string AvatarDecorationHash + { + get => Mock.Object.AvatarDecorationHash; + set => Mock.Setup(obj => obj.AvatarDecorationHash).Returns(value); + } + + public ulong? AvatarDecorationSkuId + { + get => Mock.Object.AvatarDecorationSkuId; + set => Mock.Setup(obj => obj.AvatarDecorationSkuId).Returns(value); + } + + public PrimaryGuild? PrimaryGuild + { + get => Mock.Object.PrimaryGuild; + set => Mock.Setup(obj => obj.PrimaryGuild).Returns(value); + } + + public static TestSocketUser FromUser(IUser? user) + { + if (user is null) + throw new ArgumentNullException(nameof(user)); + + return new TestSocketUser + { + IsBot = user.IsBot, + Username = user.Username, + DiscriminatorValue = user.DiscriminatorValue, + AvatarId = user.AvatarId, + IsWebhook = user.IsWebhook, + GlobalName = user.GlobalName, + AvatarDecorationHash = user.AvatarDecorationHash, + AvatarDecorationSkuId = user.AvatarDecorationSkuId, + PrimaryGuild = user.PrimaryGuild + }; + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Models/TestUser.cs b/InstarBot.Tests.Orchestrator/Models/TestUser.cs similarity index 77% rename from InstarBot.Tests.Common/Models/TestUser.cs rename to InstarBot.Tests.Orchestrator/Models/TestUser.cs index c815a9d..3678d2f 100644 --- a/InstarBot.Tests.Common/Models/TestUser.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestUser.cs @@ -1,13 +1,18 @@ -using Discord; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; +using Discord; +using Moq; using PaxAndromeda.Instar; -namespace InstarBot.Tests.Models; +namespace InstarBot.Test.Framework.Models; [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] [SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] -public class TestUser : IUser +public class TestUser : IMockOf, IUser { + public Mock DMChannelMock { get; } = new(); + + public Mock Mock { get; } = new(); + public TestUser(Snowflake snowflake) { Id = snowflake; @@ -45,27 +50,27 @@ public TestUser(TestGuildUser guildUser) public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) { - return string.Empty; + return Mock.Object.GetAvatarUrl(format, size); } public string GetDefaultAvatarUrl() { - return string.Empty; + return Mock.Object.GetDefaultAvatarUrl(); } public string GetDisplayAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) { - throw new NotImplementedException(); + return Mock.Object.GetDisplayAvatarUrl(format, size); } - public Task CreateDMChannelAsync(RequestOptions? options = null) + public Task CreateDMChannelAsync(RequestOptions options = null) { - throw new NotImplementedException(); + return Task.FromResult(DMChannelMock.Object); } public string GetAvatarDecorationUrl() { - return string.Empty; + return Mock.Object.GetAvatarDecorationUrl(); } public string AvatarId { get; set; } diff --git a/InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs b/InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs new file mode 100644 index 0000000..d08c8b5 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs @@ -0,0 +1,27 @@ +using Discord; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Test.Framework.Services; + +public class TestAutoMemberSystem : IMockOf, IAutoMemberSystem +{ + public Mock Mock { get; } = new(); + + public Task Start() + { + return Mock.Object.Start(); + } + + public Task RunAsync() + { + return Mock.Object.RunAsync(); + } + + public MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IGuildUser user) + { + return Mock.Object.CheckEligibility(cfg, user); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs b/InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs new file mode 100644 index 0000000..4763b93 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs @@ -0,0 +1,23 @@ +using Discord; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Test.Framework.Services; + +public class TestBirthdaySystem : IBirthdaySystem +{ + public Task Start() + { + return Task.CompletedTask; + } + + public Task RunAsync() + { + return Task.CompletedTask; + } + + public Task GrantUnexpectedBirthday(IGuildUser user, Birthday birthday) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs b/InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs new file mode 100644 index 0000000..63fd6b2 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs @@ -0,0 +1,131 @@ +using System.Diagnostics.CodeAnalysis; +using Amazon.DynamoDBv2.DataModel; +using Discord; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Test.Framework.Services; + +public class TestDatabaseService : IMockOf, IDatabaseService +{ + private readonly TimeProvider _timeProvider; + private readonly Dictionary _userDataTable; + private readonly Dictionary _notifications; + private readonly Mock _contextMock; + + // Although we don't mock anything here, we use this to + // throw exceptions if they're configured. + public Mock Mock { get; } = new(); + + public TestDatabaseService(TimeProvider timeProvider) + { + _timeProvider = timeProvider; + _userDataTable = new Dictionary(); + _notifications = []; + _contextMock = new Mock(); + + SetupContextMock(_userDataTable, data => data.UserID!); + SetupContextMock(_notifications, notif => notif.Date); + } + + private void SetupContextMock(Dictionary mapPointer, Func keySelector) + { + _contextMock.Setup(n => n.DeleteAsync(It.IsAny())).Callback((T data, CancellationToken _) => + { + var key = keySelector(data); + mapPointer.Remove(key); + }); + + _contextMock.Setup(n => n.SaveAsync(It.IsAny())).Callback((T data, CancellationToken _) => + { + var key = keySelector(data); + mapPointer[key] = data; + }); + } + + public Task?> GetUserAsync(Snowflake snowflake) + { + Mock.Object.GetUserAsync(snowflake); + + return !_userDataTable.TryGetValue(snowflake, out var userData) + ? Task.FromResult>(null) + : Task.FromResult(new InstarDatabaseEntry(_contextMock.Object, userData)); + } + + public Task> GetOrCreateUserAsync(IGuildUser user) + { + Mock.Object.GetOrCreateUserAsync(user); + + if (!_userDataTable.TryGetValue(user.Id, out var userData)) + { + userData = InstarUserData.CreateFrom(user); + _userDataTable[user.Id] = userData; + } + + return Task.FromResult(new InstarDatabaseEntry(_contextMock.Object, userData)); + } + + [SuppressMessage("ReSharper", "PossibleMultipleEnumeration", Justification = "Doesn't actually enumerate multiple times. First 'enumeration' is a mock which does nothing.")] + public Task>> GetBatchUsersAsync(IEnumerable snowflakes) + { + Mock.Object.GetBatchUsersAsync(snowflakes); + + var returnList = new List(); + foreach (Snowflake snowflake in snowflakes) + { + if (_userDataTable.TryGetValue(snowflake, out var userData)) + returnList.Add(userData); + } + + return Task.FromResult(returnList.Select(data => new InstarDatabaseEntry(_contextMock.Object, data)).ToList()); + } + + public Task CreateUserAsync(InstarUserData data) + { + Mock.Object.CreateUserAsync(data); + + _userDataTable[data.UserID!] = data; + return Task.CompletedTask; + } + + public Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness) + { + Mock.Object.GetUsersByBirthday(birthdate, fuzziness); + + var startUtc = birthdate.ToUniversalTime() - fuzziness; + var endUtc = birthdate.ToUniversalTime() + fuzziness; + + var matchedUsers = _userDataTable.Values.Where(userData => + { + if (userData.Birthday == null) + return false; + var userBirthdateThisYear = userData.Birthday.Observed.ToUniversalTime(); + return userBirthdateThisYear >= startUtc && userBirthdateThisYear <= endUtc; + }).ToList(); + + return Task.FromResult(matchedUsers.Select(data => new InstarDatabaseEntry(_contextMock.Object, data)).ToList()); + } + + public Task>> GetPendingNotifications() + { + Mock.Object.GetPendingNotifications(); + + var currentTimeUtc = _timeProvider.GetUtcNow(); + + var pendingNotifications = _notifications.Values + .Where(notification => notification.Date <= currentTimeUtc) + .ToList(); + + return Task.FromResult(pendingNotifications.Select(data => new InstarDatabaseEntry(_contextMock.Object, data)).ToList()); + } + + public Task> CreateNotificationAsync(Notification notification) + { + Mock.Object.CreateNotificationAsync(notification); + + _notifications[notification.Date] = notification; + return Task.FromResult(new InstarDatabaseEntry(_contextMock.Object, notification)); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs b/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs new file mode 100644 index 0000000..29d13e6 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs @@ -0,0 +1,143 @@ +using Discord; +using InstarBot.Test.Framework.Models; +using JetBrains.Annotations; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Modals; +using PaxAndromeda.Instar.Services; +using System.Threading.Channels; + +namespace InstarBot.Test.Framework.Services; + +public class TestDiscordService : IDiscordService +{ + private readonly AsyncEvent _userJoinedEvent = new(); + private readonly AsyncEvent _userLeftEvent = new(); + private readonly AsyncEvent _userUpdatedEvent = new(); + private readonly AsyncEvent _messageReceivedEvent = new(); + private readonly AsyncEvent _messageDeletedEvent = new(); + + public event Func UserJoined + { + add => _userJoinedEvent.Add(value); + remove => _userJoinedEvent.Remove(value); + } + + public event Func UserLeft + { + add => _userLeftEvent.Add(value); + remove => _userLeftEvent.Remove(value); + } + + public event Func UserUpdated + { + add => _userUpdatedEvent.Add(value); + remove => _userUpdatedEvent.Remove(value); + } + + public event Func MessageReceived + { + add => _messageReceivedEvent.Add(value); + remove => _messageReceivedEvent.Remove(value); + } + + public event Func MessageDeleted + { + add => _messageDeletedEvent.Add(value); + remove => _messageDeletedEvent.Remove(value); + } + + private readonly TestGuild _guild; + + public TestDiscordService(TestDiscordContext context) + { + var users = context.Users.Select(IGuildUser (n) => n).ToList(); + var channels = context.Channels.Select(ITextChannel (n) => n); + var roles = context.Roles.ToDictionary(n => new Snowflake(n.Id), IRole (n) => n); + + _guild = new TestGuild + { + Id = context.GuildId, + Roles = roles, + TextChannels = channels, + Users = users + }; + } + + public Task Start(IServiceProvider provider) => Task.CompletedTask; + + public IInstarGuild GetGuild() + { + return _guild; + } + + public Task> GetAllUsers() + { + return Task.FromResult(_guild.Users.AsEnumerable()); + } + + public Task GetChannel(Snowflake channelId) + { + return Task.FromResult(_guild.GetTextChannel(channelId)); + } + + public IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime) + { + var result = from channel in guild.TextChannels.OfType() + from message in channel.Messages + where message.Timestamp > afterTime + select message; + + return result.ToAsyncEnumerable(); + } + + public IGuildUser? GetUser(Snowflake snowflake) + { + return _guild.Users.FirstOrDefault(n => n.Id == snowflake.ID); + } + + public IEnumerable GetAllUsersWithRole(Snowflake roleId) + { + return _guild.Users.Where(n => n.RoleIds.Contains(roleId)); + } + + public Task SyncUsers() + { + return Task.CompletedTask; + } + + public void CreateChannel(Snowflake channelId) + { + _guild.AddChannel(new TestChannel(channelId)); + } + + public TestGuildUser CreateUser(Snowflake userId) + { + return CreateUser(new TestGuildUser(userId) + { + GuildId = GetGuild().Id + }); + } + + public TestGuildUser CreateUser(TestGuildUser user) + { + user.GuildId = GetGuild().Id; + _guild.AddUser(user); + return user; + } + + public async Task TriggerUserJoined(IGuildUser user) + { + await _userJoinedEvent.Invoke(user); + } + + public async Task TriggerUserUpdated(UserUpdatedEventArgs args) + { + await _userUpdatedEvent.Invoke(args); + } + + [UsedImplicitly] + public async Task TriggerMessageReceived(IMessage message) + { + await _messageReceivedEvent.Invoke(message); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Services/TestDynamicConfigService.cs b/InstarBot.Tests.Orchestrator/Services/TestDynamicConfigService.cs new file mode 100644 index 0000000..12e061d --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Services/TestDynamicConfigService.cs @@ -0,0 +1,43 @@ +using JetBrains.Annotations; +using Newtonsoft.Json; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Test.Framework.Services; + +public sealed class TestDynamicConfigService : IDynamicConfigService +{ + private readonly string _configPath; + private readonly Dictionary _parameters = new(); + private InstarDynamicConfiguration _config = null!; + + public TestDynamicConfigService(string configPath) + { + _configPath = configPath; + + Task.Run(Initialize).Wait(); + } + + [UsedImplicitly] + public TestDynamicConfigService(string configPath, Dictionary parameters) + : this(configPath) + { + _parameters = parameters; + } + + public Task GetConfig() + { + return Task.FromResult(_config); + } + + public Task GetParameter(string parameterName) + { + return Task.FromResult(_parameters[parameterName])!; + } + + public async Task Initialize() + { + var data = await File.ReadAllTextAsync(_configPath); + _config = JsonConvert.DeserializeObject(data)!; + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs b/InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs new file mode 100644 index 0000000..d45d3f3 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs @@ -0,0 +1,95 @@ +using InstarBot.Test.Framework.Models; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Gaius; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Test.Framework.Services; + +public sealed class TestGaiusAPIService : IGaiusAPIService +{ + private readonly Dictionary> _warnings; + private readonly Dictionary> _caselogs; + private bool _inhibit; + + public void Inhibit() + { + _inhibit = true; + } + + public void AddWarning(TestGuildUser user, Warning warning) + { + if (!_warnings.ContainsKey(user.Id)) + _warnings[user.Id] = []; + + _warnings[user.Id].Add(warning); + } + + public void AddCaselog(TestGuildUser user, Caselog caselog) + { + if (!_caselogs.ContainsKey(user.Id)) + _caselogs[user.Id] = [ ]; + + _caselogs[user.Id].Add(caselog); + } + + public TestGaiusAPIService() : + this(false) { } + + public TestGaiusAPIService(bool inhibit) + : this(new Dictionary>(), new Dictionary>(), inhibit) + { } + + public TestGaiusAPIService(Dictionary> warnings, + Dictionary> caselogs, + bool inhibit = false) + { + _warnings = warnings; + _caselogs = caselogs; + _inhibit = inhibit; + } + + public void Dispose() + { + // do nothing + } + + public Task> GetAllWarnings() + { + return Task.FromResult>(_warnings.Values.SelectMany(list => list).ToList()); + } + + public Task> GetAllCaselogs() + { + return Task.FromResult>(_caselogs.Values.SelectMany(list => list).ToList()); + } + + public Task> GetWarningsAfter(DateTime dt) + { + return Task.FromResult>(from list in _warnings.Values from item in list where item.WarnDate > dt select item); + } + + public Task> GetCaselogsAfter(DateTime dt) + { + return Task.FromResult>(from list in _caselogs.Values from item in list where item.Date > dt select item); + } + + public Task?> GetWarnings(Snowflake userId) + { + if (_inhibit) + return Task.FromResult?>(null); + + return !_warnings.TryGetValue(userId, out var warning) + ? Task.FromResult?>([]) + : Task.FromResult?>(warning); + } + + public Task?> GetCaselogs(Snowflake userId) + { + if (_inhibit) + return Task.FromResult?>(null); + + return !_caselogs.TryGetValue(userId, out var caselog) + ? Task.FromResult?>([]) + : Task.FromResult?>(caselog); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Common/Services/MockMetricService.cs b/InstarBot.Tests.Orchestrator/Services/TestMetricService.cs similarity index 67% rename from InstarBot.Tests.Common/Services/MockMetricService.cs rename to InstarBot.Tests.Orchestrator/Services/TestMetricService.cs index 4a9c115..5625acb 100644 --- a/InstarBot.Tests.Common/Services/MockMetricService.cs +++ b/InstarBot.Tests.Orchestrator/Services/TestMetricService.cs @@ -2,9 +2,9 @@ using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; -namespace InstarBot.Tests.Services; +namespace InstarBot.Test.Framework.Services; -public sealed class MockMetricService : IMetricService +public sealed class TestMetricService : IMetricService { private readonly List<(Metric, double)> _emittedMetrics = []; @@ -14,6 +14,12 @@ public Task Emit(Metric metric, double value) return Task.FromResult(true); } + public Task Emit(Metric metric, double value, Dictionary dimensions) + { + _emittedMetrics.Add((metric, value)); + return Task.FromResult(true); + } + [UsedImplicitly] public IEnumerable GetMetricValues(Metric metric) { diff --git a/InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs b/InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs new file mode 100644 index 0000000..7f87c4b --- /dev/null +++ b/InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs @@ -0,0 +1,41 @@ +using Discord; +using InstarBot.Test.Framework.Models; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.DynamoModels; + +namespace InstarBot.Test.Framework; + +public class TestDatabaseContext +{ +} + +public class TestDatabaseContextBuilder +{ + private readonly TestDiscordContextBuilder? _discordContextBuilder; + + private Dictionary _registeredUsers = new(); + + public TestDatabaseContextBuilder(ref TestDiscordContextBuilder? discordContextBuilder) + { + _discordContextBuilder = discordContextBuilder; + } + + public TestDatabaseContextBuilder RegisterUser(Snowflake userId) + => RegisterUser(userId, x => x); + + public TestDatabaseContextBuilder RegisterUser(Snowflake userId, Func editExpr) + { + if (_registeredUsers.TryGetValue(userId, out InstarUserData? userData)) + { + _registeredUsers[userId] = editExpr(userData); + return this; + } + + if (_discordContextBuilder is null || !_discordContextBuilder.TryGetUser(userId, out TestGuildUser guildUser)) + throw new InvalidOperationException($"You must register {userId.ID} as a Discord user before calling this method."); + + _registeredUsers[userId] = editExpr(InstarUserData.CreateFrom(guildUser)); + + return this; + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs b/InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs new file mode 100644 index 0000000..934370a --- /dev/null +++ b/InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs @@ -0,0 +1,134 @@ +using System.Collections.ObjectModel; +using Discord; +using InstarBot.Test.Framework.Models; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.Services; + +namespace InstarBot.Test.Framework; + +public class TestDiscordContext +{ + public Snowflake GuildId { get; } + public ReadOnlyCollection Users { get; } + public ReadOnlyCollection Channels { get; } + public ReadOnlyCollection Roles { get; } + + public TestDiscordContext(Snowflake guildId, IEnumerable users, IEnumerable channels, IEnumerable roles) + { + GuildId = guildId; + Users = users.ToList().AsReadOnly(); + Channels = channels.ToList().AsReadOnly(); + Roles = roles.ToList().AsReadOnly(); + } + + public static TestDiscordContextBuilder Builder => new(); +} + +public class TestDiscordContextBuilder : IBuilder +{ + private Snowflake _guildId = Snowflake.Generate(); + private readonly Dictionary _registeredUsers = new(); + private readonly Dictionary _registeredChannels = new(); + private readonly Dictionary _registeredRoles = new(); + + public TestDiscordContext Build() + { + return new TestDiscordContext(_guildId, _registeredUsers.Values, _registeredChannels.Values, _registeredRoles.Values); + } + + public async Task LoadFromConfig(IDynamicConfigService configService) + { + var cfg = await configService.GetConfig(); + + _guildId = cfg.TargetGuild; + RegisterUser(cfg.BotUserID, cfg.BotName); + + RegisterChannel(cfg.TargetChannel); + RegisterChannel(cfg.StaffAnnounceChannel); + + RegisterRole(cfg.StaffRoleID, "Staff"); + RegisterRole(cfg.NewMemberRoleID, "New Member"); + RegisterRole(cfg.MemberRoleID, "Member"); + + foreach (Snowflake snowflake in cfg.AuthorizedStaffID) + RegisterRole(snowflake); + + LoadFromAutoMemberConfig(cfg.AutoMemberConfig); + LoadFromBirthdayConfig(cfg.BirthdayConfig); + LoadFromTeamsConfig(cfg.Teams); + + return this; + } + + private void LoadFromAutoMemberConfig(AutoMemberConfig cfg) + { + RegisterRole(cfg.HoldRole, "AMH"); + RegisterChannel(cfg.IntroductionChannel); + + foreach (Snowflake roleId in cfg.RequiredRoles.SelectMany(n => n.Roles)) + RegisterRole(roleId); // no names here sadly + } + + private void LoadFromBirthdayConfig(BirthdayConfig cfg) + { + RegisterRole(cfg.BirthdayRole, "Happy Birthday!"); + RegisterChannel(cfg.BirthdayAnnounceChannel); + + foreach (Snowflake snowflake in cfg.AgeRoleMap.Select(n => n.Role)) + RegisterRole(snowflake); + } + + private void LoadFromTeamsConfig(IEnumerable teams) + { + foreach (Team team in teams) + { + RegisterRole(team.ID); + RegisterUser(team.Teamleader); + } + } + + private void RegisterChannel(Snowflake snowflake) + { + if (_registeredChannels.ContainsKey(snowflake)) + return; + + _registeredChannels.Add(snowflake, new TestChannel(snowflake)); + } + + private void RegisterRole(Snowflake snowflake, string name = "Role") + { + if (_registeredRoles.ContainsKey(snowflake)) + return; + + _registeredRoles.Add(snowflake, new TestRole(snowflake) + { + Name = name + }); + } + + private void RegisterUser(Snowflake snowflake, string name = "User") + { + if (_registeredUsers.ContainsKey(snowflake)) + return; + + _registeredUsers.Add(snowflake, new TestGuildUser(snowflake) + { + GlobalName = name, + DisplayName = name, + Username = name, + Nickname = name + }); + } + + public TestDiscordContextBuilder RegisterUser(TestGuildUser user) + { + _registeredUsers.Add(user.Id, user); + return this; + } + + internal bool TryGetUser(Snowflake userId, out TestGuildUser testGuildUser) + { + return _registeredUsers.TryGetValue(userId, out testGuildUser); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/TestOrchestrator.cs b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs new file mode 100644 index 0000000..448eee3 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs @@ -0,0 +1,226 @@ +using Discord; +using InstarBot.Test.Framework.Models; +using InstarBot.Test.Framework.Services; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Services; +using Serilog; +using Serilog.Events; +using System.Diagnostics.CodeAnalysis; +using System.Reactive.Subjects; +using System.Runtime.InteropServices; +using static Microsoft.ApplicationInsights.MetricDimensionNames.TelemetryContext; +using BindingFlags = System.Reflection.BindingFlags; +using InvalidOperationException = Amazon.CloudWatchLogs.Model.InvalidOperationException; + +namespace InstarBot.Test.Framework +{ + [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] + public class TestOrchestrator + { + private readonly IServiceProvider _serviceProvider; + private readonly Snowflake _actor; + public static TestServiceProviderBuilder Builder => new(); + + public IGuildUser Actor { + get + { + var user = GetUser(_actor); + + if (user is null) + { + if (GetService() is not TestDiscordService tds) + throw new InvalidOperationException("Discord service was not mocked correctly."); + + tds.CreateUser(_actor); + user = GetUser(_actor); + } + + return user!; + } + } + + public TestGuildUser Subject { get; set; } + + public Snowflake GuildID => GetService().GetGuild().Id; + + // Shortcuts for common services + public IDatabaseService Database => GetService(); + public IDiscordService Discord => GetService(); + public IDynamicConfigService DynamicConfigService => GetService(); + public TimeProvider TimeProvider => GetService(); + + public InstarDynamicConfiguration Configuration => DynamicConfigService.GetConfig().Result; + public TestMetricService Metrics => (TestMetricService) GetService(); + public TestGuild Guild => (TestGuild) Discord.GetGuild(); + + public IServiceProvider ServiceProvider => _serviceProvider; + + internal TestOrchestrator(IServiceProvider serviceProvider, Snowflake actor) + { + SetupLogging(); + + _serviceProvider = serviceProvider; + _actor = actor; + + InitializeActor(); + InitializeSubject(CreateUser()); + } + + internal TestOrchestrator(IServiceProvider serviceProvider, Snowflake actor, TestGuildUser subject) + { + SetupLogging(); + + _serviceProvider = serviceProvider; + _actor = actor; + + InitializeActor(); + InitializeSubject(subject); + + // We need to make sure the user is also in the DiscordService + if (Discord.GetGuild() is not TestGuild tg) + throw new InvalidOperationException("Discord service was not mocked correctly."); + tg.AddUser(subject); + } + + private void InitializeSubject(TestGuildUser subject) + { + subject.GuildId = GuildID; + Subject = subject; + + if (GetService() is not TestDatabaseService tdbs) + throw new InvalidOperationException("Database service was not mocked correctly."); + + tdbs.CreateUserAsync(InstarUserData.CreateFrom(Subject)).Wait(); + } + + private void InitializeActor() + { + if (GetService() is not TestDiscordService tds) + throw new InvalidOperationException("Discord service was not mocked correctly."); + + if (tds.GetUser(_actor) is not { } user) + user = tds.CreateUser(_actor); + + if (GetService() is not TestDatabaseService tdbs) + throw new InvalidOperationException("Database service was not mocked correctly."); + + tdbs.CreateUserAsync(InstarUserData.CreateFrom(user)).Wait(); + } + + private static void SetupLogging() + { + Log.Logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.Is(LogEventLevel.Verbose) + .WriteTo.Console() + .CreateLogger(); + Log.Warning("Logging is enabled for this unit test."); + } + + public T GetService() where T : class { + return _serviceProvider.GetRequiredService(); + } + + public IGuildUser? GetUser(Snowflake snowflake) + { + var discordService = _serviceProvider.GetRequiredService(); + + return discordService.GetUser(snowflake); + } + + public Mock GetCommand(Func constructor) where T : BaseCommand + { + var constructors = typeof(T).GetConstructors(); + + var mock = new Mock(() => constructor()); + var executionChannel = Snowflake.Generate(); + + if (Discord is not TestDiscordService tds) + throw new InvalidOperationException("Discord service is not an instance of TestDiscordService"); + + tds.CreateChannel(executionChannel); + + mock.SetupGet(obj => obj.Context).Returns(new TestInteractionContext(this, _actor, executionChannel)); + + return mock; + } + + public Mock GetCommand() where T : BaseCommand + { + var constructors = typeof(T).GetConstructors(); + + // Sift through the constructors to find one that works + foreach (var constructor in constructors) + { + var parameters = constructor.GetParameters().Select(n => n.ParameterType).Select(ty => _serviceProvider.GetService(ty)).ToList(); + + if (!parameters.All(obj => obj is not null)) + continue; + + var executionChannel = Snowflake.Generate(); + + if (Discord is not TestDiscordService tds) + throw new InvalidOperationException("Discord service is not an instance of TestDiscordService"); + + tds.CreateChannel(executionChannel); + + var mock = new Mock(parameters.ToArray()!); + + mock.SetupGet(obj => obj.Context).Returns(new TestInteractionContext(this, _actor, executionChannel)); + + return mock; + } + + throw new InvalidOperationException($"Failed to find a suitable constructor for {typeof(T).FullName}!"); + } + + public TestChannel GetChannel(Snowflake channelId) + { + return Discord.GetChannel(channelId).Result as TestChannel ?? throw new InvalidOperationException("Channel was not registered for mocking."); + } + + public void SetTime(DateTimeOffset date) + { + if (GetService() is not TestTimeProvider testTimeProvider) + throw new InvalidOperationException("Time provider was not an instance of TestTimeProvider"); + + testTimeProvider.SetTime(date); + } + + public static TestOrchestrator Default => Builder.Build().Result; + + public TestGuildUser CreateUser() + { + if (Discord is not TestDiscordService tds) + throw new InvalidOleVariantTypeException("Discord service was not an instance of TestDiscordService"); + + return tds.CreateUser(Snowflake.Generate()); + } + + public async Task CreateAutoMemberHold(IGuildUser user, string reason = "Test reason") + { + var dbUser = await Database.GetOrCreateUserAsync(user); + dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord + { + Date = TimeProvider.GetUtcNow().UtcDateTime, + ModeratorID = Actor.Id, + Reason = reason + }; + await dbUser.CommitAsync(); + } + + public TestChannel CreateChannel(Snowflake channelId) + { + TestGuild guild = (TestGuild) Discord.GetGuild(); + var channel = new TestChannel(channelId); + guild.AddChannel(channel); + + return channel; + } + } +} diff --git a/InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs b/InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs new file mode 100644 index 0000000..34e6646 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs @@ -0,0 +1,132 @@ +using Discord; +using InstarBot.Test.Framework.Models; +using InstarBot.Test.Framework.Services; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.Commands; +using PaxAndromeda.Instar.Services; +using InvalidOperationException = Amazon.CloudWatchLogs.Model.InvalidOperationException; + +namespace InstarBot.Test.Framework; + +public class TestServiceProviderBuilder +{ + private const string DefaultConfigPath = "Config/Instar.dynamic.test.debug.conf.json"; + + private readonly Dictionary _serviceRegistry = new(); + private readonly Dictionary _serviceTypeRegistry = new(); + private readonly HashSet _componentRegistry = new(); + private string _configPath = DefaultConfigPath; + private TestDiscordContextBuilder? _discordContextBuilder = null; + private TestDatabaseContextBuilder? _databaseContextBuilder = null; + private readonly Dictionary _interactionCallerIds = new(); + private Snowflake _actor = Snowflake.Generate(); + + private TestGuildUser _subject = new(Snowflake.Generate()); + + public TestServiceProviderBuilder WithConfigPath(string configPath) + { + _configPath = configPath; + return this; + } + + public TestServiceProviderBuilder WithService() + { + _serviceTypeRegistry[typeof(T)] = typeof(V); + return this; + } + + public TestServiceProviderBuilder WithService(Mock serviceMock) where T : class + { + return WithService(serviceMock.Object); + } + + public TestServiceProviderBuilder WithService(T serviceMock) where T : class + { + _serviceRegistry[typeof(T)] = serviceMock; + return this; + } + + public TestServiceProviderBuilder WithTime(DateTimeOffset? time) + { + return time is null ? this : WithService(new TestTimeProvider((DateTimeOffset) time)); + } + + public TestServiceProviderBuilder WithDiscordContext(Func builderExpr) + { + _discordContextBuilder = builderExpr(TestDiscordContext.Builder); + return this; + } + + public TestServiceProviderBuilder WithDatabase(Func builderExpr) + { + _databaseContextBuilder ??= new TestDatabaseContextBuilder(ref _discordContextBuilder); + _databaseContextBuilder = builderExpr(_databaseContextBuilder); + return this; + } + + public TestServiceProviderBuilder WithActor(Snowflake userId) + { + _actor = userId; + return this; + } + + public TestServiceProviderBuilder WithSubject(TestGuildUser user) + { + _subject = user; + return this; + } + + public async Task Build() + { + var services = new ServiceCollection(); + foreach (var (type, implementation) in _serviceRegistry) + services.AddSingleton(type, implementation); + foreach (var (iType, implType) in _serviceTypeRegistry) + services.AddSingleton(iType, implType); + foreach (var type in _componentRegistry) + services.AddTransient(type); + + IDynamicConfigService configService; + if (_serviceRegistry.TryGetValue(typeof(IDynamicConfigService), out var registeredService) && + registeredService is IDynamicConfigService resolved) + configService = resolved; + else + configService = new TestDynamicConfigService(_configPath); + + _discordContextBuilder ??= TestDiscordContext.Builder; + await _discordContextBuilder.LoadFromConfig(configService); + + // Register default services + RegisterDefaultService(services, configService); + RegisterDefaultService(services, TestTimeProvider.System); + RegisterDefaultService(services); + RegisterDefaultService(services, new TestDiscordService(_discordContextBuilder.Build())); + RegisterDefaultService(services); + RegisterDefaultService(services); + RegisterDefaultService(services); + RegisterDefaultService(services); + RegisterDefaultService(services); + + return new TestOrchestrator(services.BuildServiceProvider(), _actor, _subject); + } + + private void RegisterDefaultService(ServiceCollection collection) where T : class + { + if (!_serviceRegistry.ContainsKey(typeof(T)) && !_serviceTypeRegistry.ContainsKey(typeof(T))) + collection.AddSingleton(); + } + + private void RegisterDefaultService(ServiceCollection collection, T impl) where T : class + { + if (!_serviceRegistry.ContainsKey(typeof(T)) && !_serviceTypeRegistry.ContainsKey(typeof(T))) + collection.AddSingleton(impl); + } + + private void RegisterDefaultService(ServiceCollection collection) where T : class where V : class, T + { + if (!_serviceRegistry.ContainsKey(typeof(T)) && !_serviceTypeRegistry.ContainsKey(typeof(T))) + collection.AddSingleton(); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/TestTimeProvider.cs b/InstarBot.Tests.Orchestrator/TestTimeProvider.cs new file mode 100644 index 0000000..45cf7f9 --- /dev/null +++ b/InstarBot.Tests.Orchestrator/TestTimeProvider.cs @@ -0,0 +1,28 @@ +namespace InstarBot.Test.Framework; + +public class TestTimeProvider : TimeProvider +{ + public new static TestTimeProvider System => new(); + + private DateTimeOffset? _time; + + public TestTimeProvider() + { + _time = null; + } + + public TestTimeProvider(DateTimeOffset time) + { + SetTime(time); + } + + public void SetTime(DateTimeOffset time) + { + _time = time; + } + + public override DateTimeOffset GetUtcNow() + { + return _time is null ? DateTimeOffset.UtcNow : ((DateTimeOffset) _time).ToUniversalTime(); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj index 73c5914..4b438cd 100644 --- a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj +++ b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj @@ -29,6 +29,7 @@ + diff --git a/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs b/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs index c2cb16b..2cd9cbc 100644 --- a/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs +++ b/InstarBot.Tests.Unit/Preconditions/RequireStaffMemberAttributeTests.cs @@ -1,12 +1,12 @@ -using System.Collections.Generic; -using System.Threading.Tasks; using Discord; using FluentAssertions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Moq; -using PaxAndromeda.Instar; using PaxAndromeda.Instar.Preconditions; +using System.Collections.Generic; +using System.Threading.Tasks; +using InstarBot.Test.Framework; using Xunit; namespace InstarBot.Tests.Preconditions; @@ -23,13 +23,14 @@ public async Task CheckRequirementsAsync_ShouldReturnFalse_WithBadConfig() .AddInMemoryCollection(new Dictionary()) .Build()); - var context = TestUtilities.SetupContext(new TestContext - { - UserRoles = [new Snowflake(793607635608928257)] - }); + var orchestrator = TestOrchestrator.Default; + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.StaffRoleID); - // Act - var result = await attr.CheckRequirementsAsync(context.Object, null!, serviceColl.BuildServiceProvider()); + var context = new Mock(); + context.Setup(n => n.User).Returns(orchestrator.Subject); + + // Act + var result = await attr.CheckRequirementsAsync(context.Object, null!, serviceColl.BuildServiceProvider()); // Assert result.IsSuccess.Should().BeFalse(); @@ -41,11 +42,13 @@ public async Task CheckRequirementsAsync_ShouldReturnFalse_WithNonGuildUser() // Arrange var attr = new RequireStaffMemberAttribute(); - var context = new Mock(); + var orchestrator = TestOrchestrator.Default; + + var context = new Mock(); context.Setup(n => n.User).Returns(Mock.Of()); // Act - var result = await attr.CheckRequirementsAsync(context.Object, null!, TestUtilities.GetServices()); + var result = await attr.CheckRequirementsAsync(context.Object, null!, orchestrator.ServiceProvider); // Assert result.IsSuccess.Should().BeFalse(); @@ -57,13 +60,14 @@ public async Task CheckRequirementsAsync_ShouldReturnSuccessful_WithValidUser() // Arrange var attr = new RequireStaffMemberAttribute(); - var context = TestUtilities.SetupContext(new TestContext - { - UserRoles = [new Snowflake(793607635608928257)] - }); + var orchestrator = TestOrchestrator.Default; + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.StaffRoleID); - // Act - var result = await attr.CheckRequirementsAsync(context.Object, null!, TestUtilities.GetServices()); + var context = new Mock(); + context.Setup(n => n.User).Returns(orchestrator.Subject); + + // Act + var result = await attr.CheckRequirementsAsync(context.Object, null!, orchestrator.ServiceProvider); // Assert result.IsSuccess.Should().BeTrue(); @@ -74,14 +78,15 @@ public async Task CheckRequirementsAsync_ShouldReturnFailure_WithNonStaffUser() { // Arrange var attr = new RequireStaffMemberAttribute(); + + var orchestrator = TestOrchestrator.Default; + await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); - var context = TestUtilities.SetupContext(new TestContext - { - UserRoles = [new Snowflake()] - }); + var context = new Mock(); + context.Setup(n => n.User).Returns(orchestrator.Subject); - // Act - var result = await attr.CheckRequirementsAsync(context.Object, null!, TestUtilities.GetServices()); + // Act + var result = await attr.CheckRequirementsAsync(context.Object, null!, orchestrator.ServiceProvider); // Assert result.IsSuccess.Should().BeFalse(); diff --git a/InstarBot.Tests.Unit/SnowflakeTests.cs b/InstarBot.Tests.Unit/SnowflakeTests.cs index 08f3126..29127bf 100644 --- a/InstarBot.Tests.Unit/SnowflakeTests.cs +++ b/InstarBot.Tests.Unit/SnowflakeTests.cs @@ -186,9 +186,8 @@ public void Generate_ShouldBeUnique() var snowflake1 = Snowflake.Generate(); var snowflake2 = Snowflake.Generate(); - // Assert - snowflake1.GeneratedId.Should().Be(1); - snowflake2.GeneratedId.Should().Be(2); + // Assert + snowflake2.GeneratedId.Should().BeGreaterThan(snowflake1.GeneratedId); snowflake1.Should().NotBe(snowflake2); } diff --git a/InstarBot.sln b/InstarBot.sln index 1512192..e69bdfb 100644 --- a/InstarBot.sln +++ b/InstarBot.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.4.33213.308 +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11304.174 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InstarBot", "InstarBot\InstarBot.csproj", "{5663F6B4-9A4E-4007-9D2B-8552BB3645A6}" EndProject @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InstarBot.Tests.Common", "I EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{7F053D1A-E45A-4D1E-B667-9A678B46138B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InstarBot.Test.Framework", "InstarBot.Tests.Orchestrator\InstarBot.Test.Framework.csproj", "{00CF1D99-E691-4E8F-ABDE-9167E60CFE35}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -35,16 +37,21 @@ Global {1136B00A-B649-4D85-8DF3-D506C7726EAA}.Debug|Any CPU.Build.0 = Debug|Any CPU {1136B00A-B649-4D85-8DF3-D506C7726EAA}.Release|Any CPU.ActiveCfg = Release|Any CPU {1136B00A-B649-4D85-8DF3-D506C7726EAA}.Release|Any CPU.Build.0 = Release|Any CPU + {00CF1D99-E691-4E8F-ABDE-9167E60CFE35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00CF1D99-E691-4E8F-ABDE-9167E60CFE35}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00CF1D99-E691-4E8F-ABDE-9167E60CFE35}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00CF1D99-E691-4E8F-ABDE-9167E60CFE35}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {9C6230CA-16D9-482C-8576-82F3F7D72843} - EndGlobalSection GlobalSection(NestedProjects) = preSolution - {1136B00A-B649-4D85-8DF3-D506C7726EAA} = {7F053D1A-E45A-4D1E-B667-9A678B46138B} - {6013C4E2-F315-4F98-B9CF-8A7700F6E0EF} = {7F053D1A-E45A-4D1E-B667-9A678B46138B} {765E61BC-2331-4AC2-BE0D-372F87D4B85F} = {7F053D1A-E45A-4D1E-B667-9A678B46138B} + {6013C4E2-F315-4F98-B9CF-8A7700F6E0EF} = {7F053D1A-E45A-4D1E-B667-9A678B46138B} + {1136B00A-B649-4D85-8DF3-D506C7726EAA} = {7F053D1A-E45A-4D1E-B667-9A678B46138B} + {00CF1D99-E691-4E8F-ABDE-9167E60CFE35} = {7F053D1A-E45A-4D1E-B667-9A678B46138B} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {9C6230CA-16D9-482C-8576-82F3F7D72843} EndGlobalSection EndGlobal diff --git a/InstarBot/AppContext.cs b/InstarBot/AppContext.cs index 0dc5254..694cc9a 100644 --- a/InstarBot/AppContext.cs +++ b/InstarBot/AppContext.cs @@ -2,4 +2,5 @@ [assembly: InternalsVisibleTo("InstarBot.Tests.Unit")] [assembly: InternalsVisibleTo("InstarBot.Tests.Common")] -[assembly: InternalsVisibleTo("InstarBot.Tests.Integration")] \ No newline at end of file +[assembly: InternalsVisibleTo("InstarBot.Tests.Integration")] +[assembly: InternalsVisibleTo("InstarBot.Test.Framework")] \ No newline at end of file diff --git a/InstarBot/Commands/AutoMemberHoldCommand.cs b/InstarBot/Commands/AutoMemberHoldCommand.cs index 7bc606d..5f1d44a 100644 --- a/InstarBot/Commands/AutoMemberHoldCommand.cs +++ b/InstarBot/Commands/AutoMemberHoldCommand.cs @@ -11,7 +11,7 @@ namespace PaxAndromeda.Instar.Commands; [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class AutoMemberHoldCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfigService, IMetricService metricService, TimeProvider timeProvider) : BaseCommand +public class AutoMemberHoldCommand(IDatabaseService ddbService, IDynamicConfigService dynamicConfigService, IMetricService metricService, TimeProvider timeProvider) : BaseCommand { [UsedImplicitly] [SlashCommand("amh", "Withhold automatic membership grants to a user.")] @@ -60,7 +60,7 @@ string reason Reason = reason, Date = date }; - await dbUser.UpdateAsync(); + await dbUser.CommitAsync(); // TODO: configurable duration? await RespondAsync(string.Format(Strings.Command_AutoMemberHold_Success, user.Id), ephemeral: true); @@ -106,7 +106,7 @@ IUser user } dbUser.Data.AutoMemberHoldRecord = null; - await dbUser.UpdateAsync(); + await dbUser.CommitAsync(); await RespondAsync(string.Format(Strings.Command_AutoMemberUnhold_Success, user.Id), ephemeral: true); } diff --git a/InstarBot/Commands/CheckEligibilityCommand.cs b/InstarBot/Commands/CheckEligibilityCommand.cs index d429cb3..0646b62 100644 --- a/InstarBot/Commands/CheckEligibilityCommand.cs +++ b/InstarBot/Commands/CheckEligibilityCommand.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Discord; using Discord.Interactions; using JetBrains.Annotations; @@ -6,7 +7,6 @@ using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Serilog; -using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Commands; @@ -14,7 +14,7 @@ namespace PaxAndromeda.Instar.Commands; public class CheckEligibilityCommand( IDynamicConfigService dynamicConfig, IAutoMemberSystem autoMemberSystem, - IInstarDDBService ddbService, + IDatabaseService ddbService, IMetricService metricService) : BaseCommand { diff --git a/InstarBot/Commands/PageCommand.cs b/InstarBot/Commands/PageCommand.cs index ac885a0..04b706c 100644 --- a/InstarBot/Commands/PageCommand.cs +++ b/InstarBot/Commands/PageCommand.cs @@ -1,4 +1,5 @@ -using Ardalis.GuardClauses; +using System.Diagnostics.CodeAnalysis; +using Ardalis.GuardClauses; using Discord; using Discord.Interactions; using JetBrains.Annotations; @@ -8,7 +9,6 @@ using PaxAndromeda.Instar.Preconditions; using PaxAndromeda.Instar.Services; using Serilog; -using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Commands; diff --git a/InstarBot/Commands/ResetBirthdayCommand.cs b/InstarBot/Commands/ResetBirthdayCommand.cs index 44914d6..0b460c1 100644 --- a/InstarBot/Commands/ResetBirthdayCommand.cs +++ b/InstarBot/Commands/ResetBirthdayCommand.cs @@ -7,7 +7,7 @@ namespace PaxAndromeda.Instar.Commands; -public class ResetBirthdayCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfig) : BaseCommand +public class ResetBirthdayCommand(IDatabaseService ddbService, IDynamicConfigService dynamicConfig) : BaseCommand { /* * Concern: This command runs very slowly, and might end up hitting the 3-second limit from Discord. @@ -33,7 +33,7 @@ IUser user dbUser.Data.Birthday = null; dbUser.Data.Birthdate = null; - await dbUser.UpdateAsync(); + await dbUser.CommitAsync(); if (user is IGuildUser guildUser) { diff --git a/InstarBot/Commands/SetBirthdayCommand.cs b/InstarBot/Commands/SetBirthdayCommand.cs index af2d894..309effe 100644 --- a/InstarBot/Commands/SetBirthdayCommand.cs +++ b/InstarBot/Commands/SetBirthdayCommand.cs @@ -1,4 +1,5 @@ -using Discord; +using System.Diagnostics.CodeAnalysis; +using Discord; using Discord.Interactions; using Discord.WebSocket; using JetBrains.Annotations; @@ -8,13 +9,12 @@ using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Serilog; -using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Commands; // Required to be unsealed for mocking [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class SetBirthdayCommand(IInstarDDBService ddbService, IDynamicConfigService dynamicConfig, IMetricService metricService, IBirthdaySystem birthdaySystem, TimeProvider timeProvider) : BaseCommand +public class SetBirthdayCommand(IDatabaseService ddbService, IDynamicConfigService dynamicConfig, IMetricService metricService, IBirthdaySystem birthdaySystem, TimeProvider timeProvider) : BaseCommand { /// /// The default year to use when none is provided. We select a year that is sufficiently @@ -123,7 +123,7 @@ await RespondAsync( }; } - await dbUser.UpdateAsync(); + await dbUser.CommitAsync(); Log.Information("User {UserID} birthday set to {DateTime} (UTC time calculated as {UtcTime})", Context.User!.Id, diff --git a/InstarBot/DynamoModels/InstarDatabaseEntry.cs b/InstarBot/DynamoModels/InstarDatabaseEntry.cs index 938275f..a350ba3 100644 --- a/InstarBot/DynamoModels/InstarDatabaseEntry.cs +++ b/InstarBot/DynamoModels/InstarDatabaseEntry.cs @@ -21,6 +21,13 @@ public sealed class InstarDatabaseEntry(IDynamoDBContext context, T data) /// Persists changes to the underlying storage system asynchronously. /// /// A that can be used to poll or wait for results, or both - public Task UpdateAsync() + public Task CommitAsync() => context.SaveAsync(Data); + + /// + /// Asynchronously deletes the associated data from the database. + /// + /// A that can be used to poll or wait for results, or both + public Task DeleteAsync() + => context.DeleteAsync(Data); } \ No newline at end of file diff --git a/InstarBot/DynamoModels/InstarUserData.cs b/InstarBot/DynamoModels/InstarUserData.cs index f160da6..8a54478 100644 --- a/InstarBot/DynamoModels/InstarUserData.cs +++ b/InstarBot/DynamoModels/InstarUserData.cs @@ -243,7 +243,7 @@ public class InstarEnumPropertyConverter : IPropertyConverter where T : Enum { public DynamoDBEntry ToEntry(object value) { - var pos = (InstarUserPosition) value; + var pos = (T) value; var name = pos.GetAttributeOfType(); return name?.Value ?? "UNKNOWN"; @@ -253,7 +253,7 @@ public object FromEntry(DynamoDBEntry entry) { var sEntry = entry.AsString(); if (sEntry is null || string.IsNullOrWhiteSpace(entry.AsString())) - return InstarUserPosition.Unknown; + return null; var name = Utilities.ToEnum(sEntry); diff --git a/InstarBot/DynamoModels/Notification.cs b/InstarBot/DynamoModels/Notification.cs new file mode 100644 index 0000000..ac8b7dc --- /dev/null +++ b/InstarBot/DynamoModels/Notification.cs @@ -0,0 +1,70 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; +using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; + +namespace PaxAndromeda.Instar.DynamoModels; + +[DynamoDBTable("InstarNotifications")] +[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] +public class Notification +{ + [DynamoDBHashKey("guild_id", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake? GuildID { get; set; } + + [DynamoDBRangeKey("date")] + public DateTime Date { get; set; } + + [DynamoDBProperty("actor", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake? Actor { get; set; } + + [DynamoDBProperty("subject")] + public string Subject { get; set; } + + [DynamoDBProperty("channel", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake? Channel { get; set; } + + [DynamoDBProperty("target")] + public List Targets { get; set; } + + [DynamoDBProperty("priority", Converter = typeof(InstarEnumPropertyConverter))] + public NotificationPriority? Priority { get; set; } = NotificationPriority.Normal; + + [DynamoDBProperty("data")] + public NotificationData Data { get; set; } +} + +public record NotificationTarget +{ + [DynamoDBProperty("type", Converter = typeof(InstarEnumPropertyConverter))] + public NotificationTargetType Type { get; set; } + + [DynamoDBProperty("id", Converter = typeof(InstarSnowflakePropertyConverter))] + public Snowflake? Id { get; set; } +} + +public record NotificationData +{ + [DynamoDBProperty("message")] + public string? Message { get; set; } +} + +public enum NotificationTargetType +{ + [EnumMember(Value = "ROLE")] + Role, + [EnumMember(Value = "USER")] + User +} + +public enum NotificationPriority +{ + [EnumMember(Value = "LOW")] + Low, + + [EnumMember(Value = "NORMAL")] + Normal, + + [EnumMember(Value = "HIGH")] + High +} \ No newline at end of file diff --git a/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs b/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs index 0f00f4f..5a135eb 100644 --- a/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs +++ b/InstarBot/Embeds/InstarCheckEligibilityEmbed.cs @@ -1,5 +1,5 @@ -using Discord; -using System.Text; +using System.Text; +using Discord; using PaxAndromeda.Instar.ConfigModels; namespace PaxAndromeda.Instar.Embeds; diff --git a/InstarBot/Embeds/InstarEligibilityEmbed.cs b/InstarBot/Embeds/InstarEligibilityEmbed.cs index 1493e52..8294819 100644 --- a/InstarBot/Embeds/InstarEligibilityEmbed.cs +++ b/InstarBot/Embeds/InstarEligibilityEmbed.cs @@ -1,7 +1,7 @@ -using Discord; -using PaxAndromeda.Instar.DynamoModels; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Text; +using Discord; +using PaxAndromeda.Instar.DynamoModels; namespace PaxAndromeda.Instar.Embeds; diff --git a/InstarBot/IBuilder.cs b/InstarBot/IBuilder.cs new file mode 100644 index 0000000..ef77248 --- /dev/null +++ b/InstarBot/IBuilder.cs @@ -0,0 +1,6 @@ +namespace PaxAndromeda.Instar; + +public interface IBuilder +{ + T Build(); +} \ No newline at end of file diff --git a/InstarBot/InstarBot.csproj b/InstarBot/InstarBot.csproj index d7365e4..16d4c96 100644 --- a/InstarBot/InstarBot.csproj +++ b/InstarBot/InstarBot.csproj @@ -32,6 +32,7 @@ + diff --git a/InstarBot/Metrics/Metric.cs b/InstarBot/Metrics/Metric.cs index 59104d1..2515155 100644 --- a/InstarBot/Metrics/Metric.cs +++ b/InstarBot/Metrics/Metric.cs @@ -5,6 +5,13 @@ namespace PaxAndromeda.Instar.Metrics; [SuppressMessage("ReSharper", "InconsistentNaming")] public enum Metric { + + [MetricName("Schedule Deviation")] + ScheduledService_ScheduleDeviation, + + [MetricName("Runtime")] + ScheduledService_ServiceRuntime, + [MetricDimension("Service", "Paging System")] [MetricName("Pages Sent")] Paging_SentPages, diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index 81240c8..5dfd878 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -1,7 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Amazon; using Amazon.CloudWatchLogs; -using CommandLine; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using PaxAndromeda.Instar.Commands; @@ -74,8 +73,8 @@ private static async Task RunAsync(IConfiguration config) // Start up other systems List tasks = [ - _services.GetRequiredService().Initialize(), - _services.GetRequiredService().Initialize() + _services.GetRequiredService().Start(), + _services.GetRequiredService().Start() ]; Task.WaitAll(tasks); @@ -90,13 +89,14 @@ private static void InitializeLogger(IConfiguration config) #else const LogEventLevel minLevel = LogEventLevel.Information; #endif - - var logCfg = new LoggerConfiguration() - .Enrich.FromLogContext() - .MinimumLevel.Is(minLevel) + + var logCfg = new LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.Is(minLevel) .WriteTo.Console(); - var awsSection = config.GetSection("AWS"); + + var awsSection = config.GetSection("AWS"); var cwSection = awsSection.GetSection("CloudWatch"); if (cwSection.GetValue("Enabled")) { @@ -129,7 +129,7 @@ private static ServiceProvider ConfigureServices(IConfiguration config) // Services services.AddSingleton(); - services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddSingleton(); services.AddSingleton(); diff --git a/InstarBot/Services/AWSDynamicConfigService.cs b/InstarBot/Services/AWSDynamicConfigService.cs index e71edaf..1b8d7f4 100644 --- a/InstarBot/Services/AWSDynamicConfigService.cs +++ b/InstarBot/Services/AWSDynamicConfigService.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text; -using Amazon.AppConfigData; using Amazon; +using Amazon.AppConfigData; using Amazon.AppConfigData.Model; using Amazon.SimpleSystemsManagement; using Amazon.SimpleSystemsManagement.Model; diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index d2c5d72..def17c3 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; using System.Runtime.Caching; -using System.Timers; using Discord; using Discord.WebSocket; using PaxAndromeda.Instar.Caching; @@ -10,11 +9,10 @@ using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Modals; using Serilog; -using Timer = System.Timers.Timer; namespace PaxAndromeda.Instar.Services; -public sealed class AutoMemberSystem : IAutoMemberSystem +public sealed class AutoMemberSystem : ScheduledService, IAutoMemberSystem { private readonly MemoryCache _ddbCache = new("AutoMemberSystem_DDBCache"); private readonly MemoryCache _messageCache = new("AutoMemberSystem_MessageCache"); @@ -26,10 +24,9 @@ public sealed class AutoMemberSystem : IAutoMemberSystem private readonly IDynamicConfigService _dynamicConfig; private readonly IDiscordService _discord; private readonly IGaiusAPIService _gaiusApiService; - private readonly IInstarDDBService _ddbService; + private readonly IDatabaseService _ddbService; private readonly IMetricService _metricService; private readonly TimeProvider _timeProvider; - private Timer _timer = null!; /// /// Recent messages per the last AMS run @@ -37,7 +34,8 @@ public sealed class AutoMemberSystem : IAutoMemberSystem private Dictionary? _recentMessages; public AutoMemberSystem(IDynamicConfigService dynamicConfig, IDiscordService discord, IGaiusAPIService gaiusApiService, - IInstarDDBService ddbService, IMetricService metricService, TimeProvider timeProvider) + IDatabaseService ddbService, IMetricService metricService, TimeProvider timeProvider) + : base("0 * * * *", timeProvider, metricService, "Auto Member System") { _dynamicConfig = dynamicConfig; _discord = discord; @@ -53,7 +51,7 @@ public AutoMemberSystem(IDynamicConfigService dynamicConfig, IDiscordService dis discord.MessageDeleted += HandleMessageDeleted; } - public async Task Initialize() + internal override async Task Initialize() { var cfg = await _dynamicConfig.GetConfig(); @@ -64,8 +62,6 @@ public async Task Initialize() if (cfg.AutoMemberConfig.EnableGaiusCheck) await PreloadGaiusPunishments(); - - StartTimer(); } /// @@ -169,7 +165,7 @@ private async Task HandleUserJoined(IGuildUser user) case InstarUserPosition.Unknown: await user.AddRoleAsync(cfg.NewMemberRoleID); dbUser.Data.Position = InstarUserPosition.NewMember; - await dbUser.UpdateAsync(); + await dbUser.CommitAsync(); break; default: @@ -228,45 +224,11 @@ private async Task HandleUserUpdated(UserUpdatedEventArgs arg) if (changed) { Log.Information("Updated metadata for user {Username} (user ID {UserID})", arg.After.Username, arg.ID); - await user.UpdateAsync(); + await user.CommitAsync(); } } - - private void StartTimer() - { - // Since we can start the bot in the middle of an hour, - // first we must determine the time until the next top - // of hour. - var currentTime = _timeProvider.GetUtcNow().UtcDateTime; - var nextHour = new DateTime(currentTime.Year, currentTime.Month, currentTime.Day, - currentTime.Hour, 0, 0).AddHours(1); - var millisecondsRemaining = (nextHour - currentTime).TotalMilliseconds; - - // Start the timer. In elapsed step, we reset the - // duration to exactly 1 hour. - _timer = new Timer(millisecondsRemaining); - _timer.Elapsed += TimerElapsed; - _timer.Start(); - - Log.Information("Auto member system timer started, first run in {SecondsRemaining} seconds.", millisecondsRemaining / 1000); - } - - private async void TimerElapsed(object? sender, ElapsedEventArgs e) - { - try - { - // Ensure the timer's interval is exactly 1 hour - _timer.Interval = 60 * 60 * 1000; - - await RunAsync(); - } - catch - { - // ignore - } - } - public async Task RunAsync() + public override async Task RunAsync() { try { @@ -383,7 +345,7 @@ private async Task GrantMembership(InstarDynamicConfiguration cfg, IGuildUser us await user.RemoveRoleAsync(cfg.NewMemberRoleID); dbUser.Data.Position = InstarUserPosition.Member; - await dbUser.UpdateAsync(); + await dbUser.CommitAsync(); // Remove the cache entry if (_ddbCache.Contains(user.Id.ToString())) diff --git a/InstarBot/Services/BirthdaySystem.cs b/InstarBot/Services/BirthdaySystem.cs index 87b37ab..6f4aba4 100644 --- a/InstarBot/Services/BirthdaySystem.cs +++ b/InstarBot/Services/BirthdaySystem.cs @@ -7,72 +7,28 @@ using Metric = PaxAndromeda.Instar.Metrics.Metric; namespace PaxAndromeda.Instar.Services; -using System.Timers; public sealed class BirthdaySystem ( IDynamicConfigService dynamicConfig, IDiscordService discord, - IInstarDDBService ddbService, + IDatabaseService ddbService, IMetricService metricService, TimeProvider timeProvider) - : IBirthdaySystem + : ScheduledService("*/5 * * * *", timeProvider, metricService, "Birthday System"), IBirthdaySystem { /// /// The maximum age to be considered 'valid' for age role assignment. /// private const int MaximumAge = 150; - private Timer _timer = null!; - [ExcludeFromCodeCoverage] - public Task Initialize() + internal override Task Initialize() { - StartTimer(); + // nothing to do return Task.CompletedTask; } - [ExcludeFromCodeCoverage] - private void StartTimer() - { - // We need to run the birthday check every 30 minutes - // to accomodate any time zone differences. - - var currentTime = timeProvider.GetUtcNow().UtcDateTime; - - bool firstHalfHour = currentTime.Minute < 30; - DateTime currentHour = new (currentTime.Year, currentTime.Month, currentTime.Day, - currentTime.Hour, 0, 0, DateTimeKind.Utc); - - DateTime firstRun = firstHalfHour ? currentHour.AddMinutes(30) : currentHour.AddHours(1); - - var millisecondsRemaining = (firstRun - currentTime).TotalMilliseconds; - - _timer = new Timer(millisecondsRemaining); - _timer.Elapsed += TimerElapsed; - _timer.Start(); - - - - Log.Information("Birthday system timer started, first run in {SecondsRemaining} seconds.", millisecondsRemaining / 1000); - } - - [ExcludeFromCodeCoverage] - private async void TimerElapsed(object? sender, ElapsedEventArgs e) - { - try - { - // Ensure the timer's interval is exactly 30 minutes. - _timer.Interval = 30 * 60 * 1000; - - await RunAsync(); - } - catch - { - // ignore - } - } - - public async Task RunAsync() + public override async Task RunAsync() { var cfg = await dynamicConfig.GetConfig(); var currentTime = timeProvider.GetUtcNow().UtcDateTime; diff --git a/InstarBot/Services/CloudwatchMetricService.cs b/InstarBot/Services/CloudwatchMetricService.cs index 94d5a64..62c8e60 100644 --- a/InstarBot/Services/CloudwatchMetricService.cs +++ b/InstarBot/Services/CloudwatchMetricService.cs @@ -1,9 +1,11 @@ +using System.Collections.Specialized; using System.Net; using Amazon; using Amazon.CloudWatch; using Amazon.CloudWatch.Model; using Amazon.Runtime; using Microsoft.Extensions.Configuration; +using PaxAndromeda.Instar; using PaxAndromeda.Instar.Metrics; using Serilog; using Metric = PaxAndromeda.Instar.Metrics.Metric; @@ -26,8 +28,29 @@ public CloudwatchMetricService(IConfiguration config) _client = new AmazonCloudWatchClient(new AWSIAMCredential(config), RegionEndpoint.GetBySystemName(region)); } - public async Task Emit(Metric metric, double value) - { + public Task Emit(Metric metric, double value) + { + try + { + var dimensions = new Dictionary(); + + var attrs = metric.GetAttributesOfType(); + if (attrs == null) + return Emit(metric, value, dimensions); + + foreach (var dim in attrs) + dimensions.Add(dim.Name, dim.Value); + + return Emit(metric, value, dimensions); + } catch (Exception ex) + { + Log.Error(ex, "Failed to emit metric {Metric} with value {Value}", metric, value); + return Task.FromResult(false); + } + } + + public async Task Emit(Metric metric, double value, Dictionary dimensions) + { for (var attempt = 1; attempt <= MaxAttempts; attempt++) { try @@ -45,6 +68,10 @@ public async Task Emit(Metric metric, double value) if (attrs != null) foreach (var dim in attrs) { + // Always prefer the passed-in dimensions over attribute-defined ones when there's a conflict + if (!dimensions.ContainsKey(dim.Name)) + dimensions.Add(dim.Name, dim.Value); + datum.Dimensions.Add(new Dimension { Name = dim.Name, @@ -52,6 +79,9 @@ public async Task Emit(Metric metric, double value) }); } + foreach (var (dName, dValue) in dimensions) + datum.Dimensions.Add(new Dimension { Name = dName, Value = dValue }); + var response = await _client.PutMetricDataAsync(new PutMetricDataRequest { Namespace = _metricNamespace, @@ -59,7 +89,8 @@ public async Task Emit(Metric metric, double value) }); return response.HttpStatusCode == HttpStatusCode.OK; - } catch (Exception ex) when (IsTransient(ex) && attempt < MaxAttempts) + } + catch (Exception ex) when (IsTransient(ex) && attempt < MaxAttempts) { var expo = Math.Pow(2, attempt - 1); var jitter = TimeSpan.FromMilliseconds(new Random().NextDouble() * 100); diff --git a/InstarBot/Services/DiscordService.cs b/InstarBot/Services/DiscordService.cs index 0c5c66e..0ec0a34 100644 --- a/InstarBot/Services/DiscordService.cs +++ b/InstarBot/Services/DiscordService.cs @@ -1,4 +1,5 @@ -using Discord; +using System.Diagnostics.CodeAnalysis; +using Discord; using Discord.Interactions; using Discord.WebSocket; using Microsoft.Extensions.Configuration; @@ -8,7 +9,6 @@ using PaxAndromeda.Instar.Wrappers; using Serilog; using Serilog.Events; -using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Services; diff --git a/InstarBot/Services/FileSystemMetricService.cs b/InstarBot/Services/FileSystemMetricService.cs index f1d307b..6359d97 100644 --- a/InstarBot/Services/FileSystemMetricService.cs +++ b/InstarBot/Services/FileSystemMetricService.cs @@ -1,5 +1,6 @@ using System.Reflection; using PaxAndromeda.Instar.Metrics; +using Serilog; namespace PaxAndromeda.Instar.Services; @@ -22,6 +23,12 @@ public void Initialize() public Task Emit(Metric metric, double value) { + return Emit(metric, value, new Dictionary()); + } + + public Task Emit(Metric metric, double value, Dictionary dimensions) + { + Log.Debug("[METRIC] {MetricName} Value: {Value}", Enum.GetName(metric), value); return Task.FromResult(true); } diff --git a/InstarBot/Services/GaiusAPIService.cs b/InstarBot/Services/GaiusAPIService.cs index 978b18f..da88f51 100644 --- a/InstarBot/Services/GaiusAPIService.cs +++ b/InstarBot/Services/GaiusAPIService.cs @@ -1,7 +1,7 @@ using System.Diagnostics; +using System.Text; using Newtonsoft.Json; using PaxAndromeda.Instar.Gaius; -using System.Text; using PaxAndromeda.Instar.Metrics; namespace PaxAndromeda.Instar.Services; diff --git a/InstarBot/Services/IAutoMemberSystem.cs b/InstarBot/Services/IAutoMemberSystem.cs index ee221dd..16a2d7d 100644 --- a/InstarBot/Services/IAutoMemberSystem.cs +++ b/InstarBot/Services/IAutoMemberSystem.cs @@ -3,10 +3,8 @@ namespace PaxAndromeda.Instar.Services; -public interface IAutoMemberSystem +public interface IAutoMemberSystem : IScheduledService { - Task RunAsync(); - /// /// Determines the eligibility of a user for membership based on specific criteria. /// @@ -25,6 +23,4 @@ public interface IAutoMemberSystem /// /// MembershipEligibility CheckEligibility(InstarDynamicConfiguration cfg, IGuildUser user); - - Task Initialize(); } \ No newline at end of file diff --git a/InstarBot/Services/IBirthdaySystem.cs b/InstarBot/Services/IBirthdaySystem.cs index 631f9f6..e148915 100644 --- a/InstarBot/Services/IBirthdaySystem.cs +++ b/InstarBot/Services/IBirthdaySystem.cs @@ -2,11 +2,8 @@ namespace PaxAndromeda.Instar.Services; -public interface IBirthdaySystem +public interface IBirthdaySystem : IScheduledService { - Task Initialize(); - Task RunAsync(); - /// /// Grants the birthday role to a user outside the normal birthday check process. For example, a /// user sets their birthday to today via command. diff --git a/InstarBot/Services/IInstarDDBService.cs b/InstarBot/Services/IDatabaseService.cs similarity index 65% rename from InstarBot/Services/IInstarDDBService.cs rename to InstarBot/Services/IDatabaseService.cs index 1bcae19..8653cf8 100644 --- a/InstarBot/Services/IInstarDDBService.cs +++ b/InstarBot/Services/IDatabaseService.cs @@ -5,7 +5,7 @@ namespace PaxAndromeda.Instar.Services; [SuppressMessage("ReSharper", "UnusedMemberInSuper.Global")] -public interface IInstarDDBService +public interface IDatabaseService { /// /// Retrieves user data from DynamoDB for a provided . @@ -14,19 +14,19 @@ public interface IInstarDDBService /// User data associated with the provided , if any exists /// If the `position` entry does not represent a valid Task?> GetUserAsync(Snowflake snowflake); - - - /// - /// Retrieves or creates user data from a provided . - /// - /// An instance of . If a new user must be created, - /// information will be pulled from the parameter. - /// An instance of . - /// - /// When a new user is created with this method, it is *not* created in DynamoDB until - /// is called. - /// - Task> GetOrCreateUserAsync(IGuildUser user); + + + /// + /// Retrieves or creates user data from a provided . + /// + /// An instance of . If a new user must be created, + /// information will be pulled from the parameter. + /// An instance of . + /// + /// When a new user is created with this method, it is *not* created in DynamoDB until + /// is called. + /// + Task> GetOrCreateUserAsync(IGuildUser user); /// /// Retrieves a list of user data from a list of . @@ -55,4 +55,18 @@ public interface IInstarDDBService /// cref="InstarDatabaseEntry{InstarUserData}"/> objects for users whose birthdays fall within the specified range. /// Returns an empty list if no users are found. Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness); + + // TODO: documentation + Task>> GetPendingNotifications(); + + /// + /// Creates a new database representation from a provided . + /// + /// An instance of . + /// An instance of . + /// + /// When a new user is created with this method, it is *not* created in DynamoDB until + /// is called. + /// + Task> CreateNotificationAsync(Notification notification); } \ No newline at end of file diff --git a/InstarBot/Services/IMetricService.cs b/InstarBot/Services/IMetricService.cs index 1eebd1c..d59eced 100644 --- a/InstarBot/Services/IMetricService.cs +++ b/InstarBot/Services/IMetricService.cs @@ -4,5 +4,6 @@ namespace PaxAndromeda.Instar.Services; public interface IMetricService { - Task Emit(Metric metric, double value); + Task Emit(Metric metric, double value); + Task Emit(Metric metric, double value, Dictionary dimensions); } \ No newline at end of file diff --git a/InstarBot/Services/IRunnableService.cs b/InstarBot/Services/IRunnableService.cs new file mode 100644 index 0000000..af7674b --- /dev/null +++ b/InstarBot/Services/IRunnableService.cs @@ -0,0 +1,9 @@ +namespace PaxAndromeda.Instar.Services; + +public interface IRunnableService +{ + /// + /// Runs the service. + /// + Task RunAsync(); +} \ No newline at end of file diff --git a/InstarBot/Services/IScheduledService.cs b/InstarBot/Services/IScheduledService.cs new file mode 100644 index 0000000..3fe7f46 --- /dev/null +++ b/InstarBot/Services/IScheduledService.cs @@ -0,0 +1,5 @@ +namespace PaxAndromeda.Instar.Services; + +public interface IScheduledService : IStartableService, IRunnableService +{ +} \ No newline at end of file diff --git a/InstarBot/Services/IStartableService.cs b/InstarBot/Services/IStartableService.cs new file mode 100644 index 0000000..4213947 --- /dev/null +++ b/InstarBot/Services/IStartableService.cs @@ -0,0 +1,9 @@ +namespace PaxAndromeda.Instar.Services; + +public interface IStartableService +{ + /// + /// Starts the scheduled service. + /// + Task Start(); +} \ No newline at end of file diff --git a/InstarBot/Services/InstarDDBService.cs b/InstarBot/Services/InstarDynamoDBService.cs similarity index 70% rename from InstarBot/Services/InstarDDBService.cs rename to InstarBot/Services/InstarDynamoDBService.cs index da3f2b9..a847162 100644 --- a/InstarBot/Services/InstarDDBService.cs +++ b/InstarBot/Services/InstarDynamoDBService.cs @@ -1,4 +1,5 @@ -using Amazon; +using System.Diagnostics.CodeAnalysis; +using Amazon; using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.DataModel; using Amazon.DynamoDBv2.DocumentModel; @@ -6,18 +7,17 @@ using Microsoft.Extensions.Configuration; using PaxAndromeda.Instar.DynamoModels; using Serilog; -using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar.Services; [ExcludeFromCodeCoverage] -public sealed class InstarDDBService : IInstarDDBService +public sealed class InstarDynamoDBService : IDatabaseService { private readonly TimeProvider _timeProvider; private readonly DynamoDBContext _ddbContext; private readonly string _guildId; - public InstarDDBService(IConfiguration config, TimeProvider timeProvider) + public InstarDynamoDBService(IConfiguration config, TimeProvider timeProvider) { _timeProvider = timeProvider; var region = config.GetSection("AWS").GetValue("Region"); @@ -45,16 +45,21 @@ public InstarDDBService(IConfiguration config, TimeProvider timeProvider) Log.Error(ex, "Failed to get user data for {Snowflake}", snowflake); return null; } - } + } - public async Task> GetOrCreateUserAsync(IGuildUser user) - { - var data = await _ddbContext.LoadAsync(_guildId, user.Id.ToString()) ?? InstarUserData.CreateFrom(user); + public async Task> GetOrCreateUserAsync(IGuildUser user) + { + var data = await _ddbContext.LoadAsync(_guildId, user.Id.ToString()) ?? InstarUserData.CreateFrom(user); - return new InstarDatabaseEntry(_ddbContext, data); - } + return new InstarDatabaseEntry(_ddbContext, data); + } - public async Task>> GetBatchUsersAsync(IEnumerable snowflakes) + public async Task> CreateNotificationAsync(Notification notification) + { + return new InstarDatabaseEntry(_ddbContext, notification); + } + + public async Task>> GetBatchUsersAsync(IEnumerable snowflakes) { var batches = _ddbContext.CreateBatchGet(); foreach (var snowflake in snowflakes) @@ -65,6 +70,34 @@ public async Task>> GetBatchUsersAsync( return batches.Results.Select(x => new InstarDatabaseEntry(_ddbContext, x)).ToList(); } + public async Task>> GetPendingNotifications() + { + var currentTime = _timeProvider.GetUtcNow(); + + var config = new QueryOperationConfig + { + KeyExpression = new Expression + { + ExpressionStatement = "guild_id = :g AND #DATE <= :now", + ExpressionAttributeNames = new Dictionary + { + ["#DATE"] = "date" + }, + ExpressionAttributeValues = new Dictionary + { + [":g"] = _guildId, + [":now"] = currentTime.ToString("O") + } + } + }; + + var search = _ddbContext.FromQueryAsync(config); + + var results = await search.GetRemainingAsync().ConfigureAwait(false); + + return results.Select(u => new InstarDatabaseEntry(_ddbContext, u)).ToList(); + } + public async Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness) { var birthday = new Birthday(birthdate, _timeProvider); diff --git a/InstarBot/Services/NotificationService.cs b/InstarBot/Services/NotificationService.cs new file mode 100644 index 0000000..76a9ddb --- /dev/null +++ b/InstarBot/Services/NotificationService.cs @@ -0,0 +1,50 @@ +using PaxAndromeda.Instar.DynamoModels; +using Serilog; + +namespace PaxAndromeda.Instar.Services; + +public class NotificationService : ScheduledService +{ + private readonly IDatabaseService _dbService; + private readonly IDiscordService _discordService; + private readonly IDynamicConfigService _dynamicConfig; + + public NotificationService( + TimeProvider timeProvider, + IMetricService metricService, + IDatabaseService dbService, + IDiscordService discordService, + IDynamicConfigService dynamicConfig) + : base("* * * * *", timeProvider, metricService, "Notifications Service") + { + _dbService = dbService; + _discordService = discordService; + _dynamicConfig = dynamicConfig; + } + + internal override Task Initialize() + { + return Task.CompletedTask; + } + + public override async Task RunAsync() + { + var notificationQueue = new Queue>( + (await _dbService.GetPendingNotifications()).OrderByDescending(entry => entry.Data.Date) + ); + + while (notificationQueue.TryDequeue(out var notification)) + { + if (!await ProcessNotification(notification)) + continue; + + await notification.DeleteAsync(); + } + } + + private async Task ProcessNotification(InstarDatabaseEntry notification) + { + Log.Information("Processed notification from {Actor} sent on {Date}: {Message}", notification.Data.Actor.ID, notification.Data.Date, notification.Data.Data.Message); + return true; + } +} \ No newline at end of file diff --git a/InstarBot/Services/ScheduledService.cs b/InstarBot/Services/ScheduledService.cs new file mode 100644 index 0000000..9686e35 --- /dev/null +++ b/InstarBot/Services/ScheduledService.cs @@ -0,0 +1,160 @@ +using NCrontab; +using PaxAndromeda.Instar.Metrics; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using System.Diagnostics; +using System.Timers; +using Timer = System.Timers.Timer; + +namespace PaxAndromeda.Instar.Services; + +/// +/// An abstract base class for services that need to run on a scheduled basis using cron expressions. +/// +public abstract class ScheduledService : IStartableService, IRunnableService +{ + private const string ServiceDimension = "Service"; + + private readonly string _serviceName; + private readonly TimeProvider _timeProvider; + private readonly IMetricService _metricService; + private readonly CrontabSchedule _schedule; + private readonly Enricher _enricher; + private Timer? _nextRunTimer; + private DateTime _expectedNextTime; + + /// + /// Initializes a new instance of the ScheduledService class with the specified schedule, time provider, metric + /// service, and optional service name. + /// + /// A cron expression that defines the schedule on which the service should run. Must be a valid cron format string. + /// A time provider used to determine the current time for scheduling operations. + /// A metric service used to record and report service metrics. + /// An optional name for the service which is used for the "Service" dimension in emitted metrics. If not provided, the name of + /// the derived class is used. If the name cannot be determined, defaults to "Unknown Service". + /// Thrown if the provided cron expression is not valid. + protected ScheduledService(string cronExpression, TimeProvider timeProvider, IMetricService metricService, string? serviceName = null) + { + // Hacky way to get the service name if one isn't provided + try + { + _serviceName = serviceName + ?? new StackTrace().GetFrame(1)?.GetMethod()?.ReflectedType?.Name // The name of the derived class + ?? "Unknown Service"; // Fallback if all else fails + } catch + { + // Swallow any exceptions and use the fallback + _serviceName = "Unknown Service"; + } + + _timeProvider = timeProvider; + _metricService = metricService; + _enricher = new Enricher(_serviceName); + + try + { + _schedule = CrontabSchedule.Parse(cronExpression); + } catch (Exception ex) + { + throw new ArgumentException("Provided cron expression was not valid.", nameof(cronExpression), ex); + } + } + + public async Task Start() + { + _nextRunTimer = new Timer + { + AutoReset = false, + Enabled = false + }; + + _nextRunTimer.Elapsed += TimerElapsed; + + await Initialize(); + + ScheduleNext(); + } + + private void ScheduleNext() + { + if (_nextRunTimer is null) + throw new InvalidOperationException("Service has not been started. Call Start() before scheduling the next run."); + + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + _expectedNextTime = _schedule.GetNextOccurrence(utcNow); + + Log.ForContext(_enricher).Debug("[{Service}] Next scheduled start time: {NextTime}", _serviceName, _expectedNextTime); + + var timeToNextRun = _expectedNextTime - utcNow; + + _nextRunTimer.Interval = timeToNextRun.TotalMilliseconds; + _nextRunTimer.Start(); + } + + // ReSharper disable once AsyncVoidEventHandlerMethod + private async void TimerElapsed(object? _, ElapsedEventArgs elapsedEventArgs) + { + try + { + var deviation = elapsedEventArgs.SignalTime.ToUniversalTime() - _expectedNextTime; + + Log.ForContext(_enricher).Debug("[{Service}] Timer elapsed. Deviation is {Deviation}ms", _serviceName, deviation.TotalMilliseconds); + + await _metricService.Emit(Metric.ScheduledService_ScheduleDeviation, deviation.TotalMilliseconds, new Dictionary + { + [ServiceDimension] = _serviceName + }); + } catch (Exception ex) + { + Log.ForContext(_enricher).Error(ex, "Failed to emit timer deviation metric"); + } + + try + { + Stopwatch stopwatch = new(); + + stopwatch.Start(); + await RunAsync(); + stopwatch.Stop(); + + Log.ForContext(_enricher).Debug("[{Service}] Run completed. Total run time was {TotalRuntimeMs}ms", _serviceName, stopwatch.ElapsedMilliseconds); + await _metricService.Emit(Metric.ScheduledService_ServiceRuntime, stopwatch.ElapsedMilliseconds, new Dictionary + { + [ServiceDimension] = _serviceName + }); + } + catch (Exception ex) + { + Log.ForContext(_enricher).Error(ex, "Failed to execute scheduled task."); + } finally + { + ScheduleNext(); + } + } + + /// + /// Initialize the scheduled service. + /// + internal abstract Task Initialize(); + + /// + /// Executes the scheduled service at the scheduled time. + /// + public abstract Task RunAsync(); + + private class Enricher(string serviceName) : ILogEventEnricher + { + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + // Create a log event property for "ProjectSlug". + LogEventProperty logEventProperty = propertyFactory.CreateProperty( + "Service", + serviceName + ); + + // Add the property to the log event if it is not already present. + logEvent.AddPropertyIfAbsent(logEventProperty); + } + } +} \ No newline at end of file diff --git a/InstarBot/Snowflake.cs b/InstarBot/Snowflake.cs index ff4d120..fe39727 100644 --- a/InstarBot/Snowflake.cs +++ b/InstarBot/Snowflake.cs @@ -159,7 +159,7 @@ public static Snowflake Generate() var newIncrement = Interlocked.Increment(ref _increment); // Gracefully handle overflows - if (newIncrement == int.MinValue) + if (newIncrement >= 4096) { newIncrement = 0; Interlocked.Exchange(ref _increment, 0); diff --git a/InstarBot/Utilities.cs b/InstarBot/Utilities.cs index c18747e..66662bf 100644 --- a/InstarBot/Utilities.cs +++ b/InstarBot/Utilities.cs @@ -32,7 +32,7 @@ private static class EnumCache where T : Enum /// A list of attributes of the specified type associated with the enum value; /// or null if no attributes of the specified type are found. /// - public List? GetAttributesOfType() where T : Attribute + public List? GetAttributesOfType() where T : Attribute { var type = enumVal.GetType(); var membersInfo = type.GetMember(enumVal.ToString()); @@ -52,7 +52,7 @@ private static class EnumCache where T : Enum /// The first custom attribute of type if found; /// otherwise, null. /// - public T? GetAttributeOfType() where T : Attribute + public T? GetAttributeOfType() where T : Attribute { var type = enumVal.GetType(); var membersInfo = type.GetMember(enumVal.ToString()); From 7ed82c49ed0ef5c648882d6193386c87412dc5cb Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sat, 27 Dec 2025 11:26:18 -0800 Subject: [PATCH 22/53] Added notification system, updated AMH commands, refactored some code --- .../Instar.dynamic.test.debug.conf.json | 89 ++++---- InstarBot.Tests.Common/EmbedVerifier.cs | 28 +++ .../AutoMemberSystemCommandTests.cs | 103 ++++----- .../CheckEligibilityCommandTests.cs | 41 +--- .../Interactions/PageCommandTests.cs | 11 +- .../Interactions/ReportUserTests.cs | 33 ++- .../Interactions/ResetBirthdayCommandTests.cs | 7 +- .../Interactions/SetBirthdayCommandTests.cs | 89 +------- .../Services/AutoMemberSystemTests.cs | 108 +++++---- .../Services/BirthdaySystemTests.cs | 1 - .../Services/NotificationSystemTests.cs | 207 ++++++++++++++++++ InstarBot.Tests.Integration/TestTest.cs | 49 ----- .../MockExtensions.cs | 3 +- .../Models/TestChannel.cs | 20 +- .../Models/TestGuild.cs | 4 +- .../Models/TestGuildUser.cs | 15 +- .../Models/TestInteractionContext.cs | 46 +--- .../Models/TestMessage.cs | 25 ++- .../Models/TestRole.cs | 2 +- .../Models/TestSocketUser.cs | 2 + .../Models/TestUser.cs | 5 +- .../Services/TestAutoMemberSystem.cs | 2 + .../Services/TestBirthdaySystem.cs | 2 + .../Services/TestDatabaseService.cs | 66 ++++-- .../Services/TestDiscordService.cs | 5 +- .../Services/TestGaiusAPIService.cs | 45 ++-- .../TestDatabaseContextBuilder.cs | 41 ---- .../TestDiscordContextBuilder.cs | 32 ++- .../TestOrchestrator.cs | 7 +- .../TestServiceProviderBuilder.cs | 22 +- .../TestTimeProvider.cs | 2 +- .../DynamoModels/EventListTests.cs | 6 +- InstarBot/Caching/MemoryCache.cs | 10 +- InstarBot/Commands/AutoMemberHoldCommand.cs | 46 +++- InstarBot/Commands/ReportUserCommand.cs | 26 ++- InstarBot/Commands/SetBirthdayCommand.cs | 7 +- InstarBot/ConfigModels/AutoMemberConfig.cs | 17 +- .../InstarDynamicConfiguration.cs | 9 +- InstarBot/DynamoModels/EventList.cs | 2 + InstarBot/DynamoModels/InstarUserData.cs | 13 +- InstarBot/DynamoModels/Notification.cs | 64 +++++- InstarBot/Embeds/InstarEmbed.cs | 2 + InstarBot/Embeds/NotificationEmbed.cs | 54 +++++ InstarBot/IBuilder.cs | 5 +- InstarBot/IInstarGuild.cs | 4 +- InstarBot/InstarBot.csproj | 1 + InstarBot/Metrics/Metric.cs | 27 ++- InstarBot/Program.cs | 7 +- InstarBot/Services/AWSDynamicConfigService.cs | 30 +-- InstarBot/Services/AutoMemberSystem.cs | 20 ++ InstarBot/Services/BirthdaySystem.cs | 9 +- InstarBot/Services/CloudwatchMetricService.cs | 6 +- InstarBot/Services/DiscordService.cs | 2 +- InstarBot/Services/FileSystemMetricService.cs | 4 +- InstarBot/Services/IDatabaseService.cs | 11 +- InstarBot/Services/IDiscordService.cs | 2 +- InstarBot/Services/IRunnableService.cs | 2 +- InstarBot/Services/IScheduledService.cs | 4 +- InstarBot/Services/IStartableService.cs | 2 +- InstarBot/Services/InstarDynamoDBService.cs | 74 +++++-- InstarBot/Services/NTPService.cs | 71 ++++++ InstarBot/Services/NotificationService.cs | 127 +++++++++-- InstarBot/Snowflake.cs | 2 + InstarBot/Strings.Designer.cs | 20 ++ InstarBot/Strings.resx | 16 +- InstarBot/Utilities.cs | 22 ++ 66 files changed, 1155 insertions(+), 681 deletions(-) create mode 100644 InstarBot.Tests.Integration/Services/NotificationSystemTests.cs delete mode 100644 InstarBot.Tests.Integration/TestTest.cs delete mode 100644 InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs create mode 100644 InstarBot/Embeds/NotificationEmbed.cs create mode 100644 InstarBot/Services/NTPService.cs diff --git a/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json b/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json index 43b8301..0bb682b 100644 --- a/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json +++ b/InstarBot.Tests.Common/Config/Instar.dynamic.test.debug.conf.json @@ -2,20 +2,23 @@ "Testing": true, "BotName": "Instar", "BotUserID": "1113147583041392641", - "TargetGuild": 985521318080413766, - "TargetChannel": 985521318080413769, - "StaffAnnounceChannel": 985521973113286667, - "StaffRoleID": 793607635608928257, - "NewMemberRoleID": 796052052433698817, - "MemberRoleID": 793611808372031499, + "TargetGuild": "985521318080413766", + "TargetChannel": "985521318080413769", + "StaffAnnounceChannel": "985521973113286667", + "StaffRoleID": "793607635608928257", + "NewMemberRoleID": "796052052433698817", + "MemberRoleID": "793611808372031499", "AuthorizedStaffID": [ - 985521877122428978, - 1113478706250395759, - 793607635608928257 + "985521877122428978", + "1113478706250395759", + "793607635608928257" + ], + "AutoKickRoles": [ + "1454525235398185177" ], "AutoMemberConfig": { - "HoldRole": 966434762032054282, - "IntroductionChannel": 793608456644067348, + "HoldRole": "966434762032054282", + "IntroductionChannel": "793608456644067348", "MinimumJoinAge": 86400, "MinimumMessages": 30, "MinimumMessageTime": 86400, @@ -24,38 +27,38 @@ { "GroupName": "Age", "Roles": [ - 796148749843169330, - 796148761608192051, - 796148761608192051, - 796148788703658044, - 796148799805456426, - 796148820865449984, - 796148842634412033, - 796148857603883038, - 796148869855576064 + "796148749843169330", + "796148761608192051", + "796148761608192051", + "796148788703658044", + "796148799805456426", + "796148820865449984", + "796148842634412033", + "796148857603883038", + "796148869855576064" ] }, { "GroupName": "Gender Identity", "Roles": [ - 796005397436694548, - 796085733437865984, - 796085775199502357, - 796085895399735308, - 815362407237025862, - 796142271555698759, - 796147883937890395, - 815367388442525696 + "796005397436694548", + "796085733437865984", + "796085775199502357", + "796085895399735308", + "815362407237025862", + "796142271555698759", + "796147883937890395", + "815367388442525696" ] }, { "GroupName": "Pronouns", "Roles": [ - 796578609535647765, - 796578878264180736, - 796578922182475836, - 815364127501451264, - 811657756553248828 + "796578609535647765", + "796578878264180736", + "796578922182475836", + "815364127501451264", + "811657756553248828" ] } ] @@ -107,40 +110,40 @@ { "InternalID": "ffcf94e3-3080-455a-82e2-7cd9ec7eaafd", "Name": "Owner", - "ID": 1113478543599468584, - "Teamleader": 130082061988921344, + "ID": "1113478543599468584", + "Teamleader": "130082061988921344", "Color": 16766721, "Priority": 1 }, { "InternalID": "4e484ea5-3cd1-46d4-8fe8-666e34f251ad", "Name": "Admin", - "ID": 1113478785292062800, - "Teamleader": 107839904318271488, + "ID": "1113478785292062800", + "Teamleader": "107839904318271488", "Color": 15132390, "Priority": 2 }, { "InternalID": "9609125a-7e63-4110-8d50-381230ea11b2", "Name": "Moderator", - "ID": 1113478610825773107, - "Teamleader": 346194980408393728, + "ID": "1113478610825773107", + "Teamleader": "346194980408393728", "Color": 15132390, "Priority": 3 }, { "InternalID": "521dce27-9ed9-48fc-9615-dc1d77b72fdd", "Name": "Helper", - "ID": 1113478650768150671, - "Teamleader": 459078815314870283, + "ID": "1113478650768150671", + "Teamleader": "459078815314870283", "Color": 13532979, "Priority": 4 }, { "InternalID": "fe434e9a-2a69-41b6-a297-24e26ba4aebe", "Name": "Community Manager", - "ID": 957411837920567356, - "Teamleader": 340546691491168257, + "ID": "957411837920567356", + "Teamleader": "340546691491168257", "Color": 10892756, "Priority": 5 } diff --git a/InstarBot.Tests.Common/EmbedVerifier.cs b/InstarBot.Tests.Common/EmbedVerifier.cs index f17ac41..f22f65c 100644 --- a/InstarBot.Tests.Common/EmbedVerifier.cs +++ b/InstarBot.Tests.Common/EmbedVerifier.cs @@ -36,29 +36,56 @@ public void AddFieldValue(string value, bool partial = false) _fields.Add((null, value, partial)); } + private void FailTest(string component, string? expected, string? actual) + { + Assert.Fail($""" + + + Failed to match embed {component}. + + Expected: '{expected ?? ""}' + Got: '{actual ?? ""}' + """); + } + private void FailField(string? expectedName, string? expectedValue) + { + Assert.Fail($""" + + + Failed to find a matching embed field. + + Name: '{expectedName ?? ""}' + Value: '{expectedValue ?? ""}' + """); + } + public bool Verify(Embed embed) { if (!VerifyString(Title, embed.Title, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialTitle))) { Log.Error("Failed to match title: Expected '{Expected}', got '{Actual}'", Title, embed.Title); + FailTest("title", Title, embed.Title); return false; } if (!VerifyString(Description, embed.Description, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialDescription))) { Log.Error("Failed to match description: Expected '{Expected}', got '{Actual}'", Description, embed.Description); + FailTest("description", Description, embed.Description); return false; } if (!VerifyString(AuthorName, embed.Author?.Name, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialAuthorName))) { Log.Error("Failed to match author name: Expected '{Expected}', got '{Actual}'", AuthorName, embed.Author?.Name); + FailTest("author", AuthorName, embed.Author?.Name); return false; } if (!VerifyString(FooterText, embed.Footer?.Text, MatchFlags.HasFlag(EmbedVerifierMatchFlags.PartialFooterText))) { Log.Error("Failed to match footer text: Expected '{Expected}', got '{Actual}'", FooterText, embed.Footer?.Text); + FailTest("footer", FooterText, embed.Footer?.Text); return false; } @@ -73,6 +100,7 @@ private bool VerifyFields(ImmutableArray embedFields) if (!embedFields.Any(n => VerifyString(name, n.Name, partial) && VerifyString(value, n.Value, partial))) { Log.Error("Failed to match field: Expected Name '{ExpectedName}', Value '{ExpectedValue}'", name, value); + FailField(name, value); return false; } } diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs index ead5e32..0c5ef3e 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs @@ -15,66 +15,6 @@ namespace InstarBot.Tests.Integration.Interactions; public static class AutoMemberSystemCommandTests { - private const ulong NewMemberRole = 796052052433698817ul; - private const ulong MemberRole = 793611808372031499ul; - - /* - private static async Task Setup(bool setupAMH = false) - { - TestUtilities.SetupLogging(); - - // This is going to be annoying - var userID = Snowflake.Generate(); - var modID = Snowflake.Generate(); - - var mockDDB = new MockDatabaseService(); - var mockMetrics = new MockMetricService(); - - - - var user = new TestGuildUser - { - Id = userID, - Username = "username", - JoinedAt = DateTimeOffset.UtcNow, - RoleIds = [ NewMemberRole ] - }; - - var mod = new TestGuildUser - { - Id = modID, - Username = "mod_username", - JoinedAt = DateTimeOffset.UtcNow, - RoleIds = [MemberRole] - }; - - await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(user)); - await mockDDB.CreateUserAsync(InstarUserData.CreateFrom(mod)); - - if (setupAMH) - { - var ddbRecord = await mockDDB.GetUserAsync(userID); - ddbRecord.Should().NotBeNull(); - ddbRecord.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord - { - Date = DateTime.UtcNow, - ModeratorID = modID, - Reason = "test reason" - }; - await ddbRecord.CommitAsync(); - } - - var commandMock = TestUtilities.SetupCommandMock( - () => new AutoMemberHoldCommand(mockDDB, TestUtilities.GetDynamicConfiguration(), mockMetrics, TimeProvider.System), - new TestContext - { - UserID = modID - }); - - return new Context(mockDDB, mockMetrics, user, mod, commandMock); - } - */ - [Fact] public static async Task HoldMember_WithValidUserAndReason_ShouldCreateRecord() { @@ -93,6 +33,18 @@ public static async Task HoldMember_WithValidUserAndReason_ShouldCreateRecord() record.Data.AutoMemberHoldRecord.Should().NotBeNull(); record.Data.AutoMemberHoldRecord.ModeratorID.ID.Should().Be(orchestrator.Actor.Id); record.Data.AutoMemberHoldRecord.Reason.Should().Be("Test reason"); + + if (orchestrator.Database is not TestDatabaseService tds) + throw new InvalidOperationException("Expected orchestrator.Database to be TestDatabaseService!"); + + var notifications = tds.GetAllNotifications(); + notifications.Should().ContainSingle(); + + var notification = notifications.First(); + notification.Should().NotBeNull(); + + notification.Type.Should().Be(NotificationType.AutoMemberHold); + notification.ReferenceUser!.ID.Should().Be(orchestrator.Subject.Id); } [Fact] @@ -149,7 +101,7 @@ public static async Task HoldMember_WithDynamoDBError_ShouldRespondWithError() if (orchestrator.Database is not IMockOf dbMock) throw new InvalidOperationException("This test depends on the registered database implementing IMockOf"); - dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(n => n.Id == orchestrator.Subject.Id))).Throws(); + dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(guildUser => guildUser.Id == orchestrator.Subject.Id))).Throws(); // Act await cmd.Object.HoldMember(orchestrator.Subject, "Test reason"); @@ -173,6 +125,18 @@ public static async Task UnholdMember_WithValidUser_ShouldRemoveAMH() await orchestrator.CreateAutoMemberHold(orchestrator.Subject); var cmd = orchestrator.GetCommand(); + if (orchestrator.Database is not TestDatabaseService tds) + throw new InvalidOperationException("Expected orchestrator.Database to be TestDatabaseService!"); + + await tds.CreateNotificationAsync(new Notification + { + Type = NotificationType.AutoMemberHold, + ReferenceUser = orchestrator.Subject.Id, + Date = (orchestrator.TimeProvider.GetUtcNow() + TimeSpan.FromDays(7)).DateTime + }); + + tds.GetAllNotifications().Should().ContainSingle(); + // Act await cmd.Object.UnholdMember(orchestrator.Subject); @@ -182,6 +146,21 @@ public static async Task UnholdMember_WithValidUser_ShouldRemoveAMH() var afterRecord = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); afterRecord.Should().NotBeNull(); afterRecord.Data.AutoMemberHoldRecord.Should().BeNull(); + + // There is a potential asynchronous delay here, so let's keep waiting for this condition for 5 seconds. + await Task.WhenAny( + Task.Delay(5000), + Task.Factory.StartNew(async () => + { + while (true) + { + if (tds.GetAllNotifications().Count == 0) + break; + + // only poll once every 50ms + await Task.Delay(50); + } + })); } [Fact] @@ -223,7 +202,7 @@ public static async Task UnholdMember_WithDynamoError_ShouldReturnError() if (orchestrator.Database is not IMockOf dbMock) throw new InvalidOperationException("This test depends on the registered database implementing IMockOf"); - dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(n => n.Id == orchestrator.Subject.Id))).Throws(); + dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(guildUser => guildUser.Id == orchestrator.Subject.Id))).Throws(); // Act await cmd.Object.UnholdMember(orchestrator.Subject); diff --git a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs index 14e38b9..6e3d2c1 100644 --- a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs @@ -14,30 +14,13 @@ namespace InstarBot.Tests.Integration.Interactions; public static class CheckEligibilityCommandTests { - private const ulong MemberRole = 793611808372031499ul; - private const ulong NewMemberRole = 796052052433698817ul; - - private static async Task SetupOrchestrator(MembershipEligibility eligibility) + private static TestOrchestrator SetupOrchestrator(MembershipEligibility eligibility) { var orchestrator = TestOrchestrator.Default; TestAutoMemberSystem tams = (TestAutoMemberSystem) orchestrator.GetService(); tams.Mock.Setup(n => n.CheckEligibility(It.IsAny(), It.IsAny())).Returns(eligibility); - /*if (context.IsAMH) - { - var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Actor.Id); - dbUser.Should().NotBeNull(); - dbUser.Data.AutoMemberHoldRecord = new AutoMemberHoldRecord - { - Date = DateTime.UtcNow, - ModeratorID = Snowflake.Generate(), - Reason = "Testing" - }; - - await dbUser.CommitAsync(); - }*/ - return orchestrator; } @@ -53,7 +36,7 @@ private static EmbedVerifier.VerifierBuilder CreateVerifier() public static async Task CheckEligibilityCommand_WithExistingMember_ShouldEmitValidMessage() { // Arrange - var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var orchestrator = SetupOrchestrator(MembershipEligibility.Eligible); var cmd = orchestrator.GetCommand(); await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.MemberRoleID); @@ -69,7 +52,7 @@ public static async Task CheckEligibilityCommand_WithExistingMember_ShouldEmitVa public static async Task CheckEligibilityCommand_NoMemberRoles_ShouldEmitValidErrorMessage() { // Arrange - var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var orchestrator = SetupOrchestrator(MembershipEligibility.Eligible); var cmd = orchestrator.GetCommand(); // Act @@ -88,7 +71,7 @@ public static async Task CheckEligibilityCommand_Eligible_ShouldEmitValidMessage .WithDescription(Strings.Command_CheckEligibility_MessagesEligibility) .Build(); - var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var orchestrator = SetupOrchestrator(MembershipEligibility.Eligible); var cmd = orchestrator.GetCommand(); await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); @@ -112,7 +95,7 @@ public static async Task CheckEligibilityCommand_WithNewMemberAMH_ShouldEmitVali .WithField(Strings.Command_CheckEligibility_AMH_ContactStaff) .Build(); - var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var orchestrator = SetupOrchestrator(MembershipEligibility.Eligible); var cmd = orchestrator.GetCommand(); await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); @@ -145,7 +128,7 @@ public static async Task CheckEligibilityCommand_DDBError_ShouldEmitValidMessage .WithDescription(Strings.Command_CheckEligibility_MessagesEligibility) .Build(); - var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var orchestrator = SetupOrchestrator(MembershipEligibility.Eligible); var cmd = orchestrator.GetCommand(); await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); @@ -199,7 +182,7 @@ public static async Task CheckEligibilityCommand_WithBadRoles_ShouldEmitValidMes .WithField(testFieldMap, true) .Build(); - var orchestrator = await SetupOrchestrator(eligibility); + var orchestrator = SetupOrchestrator(eligibility); var cmd = orchestrator.GetCommand(); await orchestrator.Actor.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); @@ -216,7 +199,7 @@ public static async Task CheckOtherEligibility_WithEligibleMember_ShouldEmitVali { // Arrange - var orchestrator = await SetupOrchestrator(MembershipEligibility.Eligible); + var orchestrator = SetupOrchestrator(MembershipEligibility.Eligible); var cmd = orchestrator.GetCommand(); await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); @@ -237,7 +220,7 @@ public static async Task CheckOtherEligibility_WithEligibleMember_ShouldEmitVali public static async Task CheckOtherEligibility_WithIneligibleMember_ShouldEmitValidEmbed() { // Arrange - var orchestrator = await SetupOrchestrator(MembershipEligibility.MissingRoles); + var orchestrator = SetupOrchestrator(MembershipEligibility.MissingRoles); var cmd = orchestrator.GetCommand(); await orchestrator.Subject.AddRoleAsync(orchestrator.Configuration.NewMemberRoleID); @@ -258,7 +241,7 @@ public static async Task CheckOtherEligibility_WithIneligibleMember_ShouldEmitVa public static async Task CheckOtherEligibility_WithAMHedMember_ShouldEmitValidEmbed() { // Arrange - var orchestrator = await SetupOrchestrator(MembershipEligibility.MissingRoles); + var orchestrator = SetupOrchestrator(MembershipEligibility.MissingRoles); var cmd = orchestrator.GetCommand(); var verifier = CreateVerifier() @@ -292,13 +275,13 @@ public static async Task CheckOtherEligibility_WithAMHedMember_ShouldEmitValidEm public static async Task CheckOtherEligibility_WithDynamoError_ShouldEmitValidEmbed() { // Arrange - var orchestrator = await SetupOrchestrator(MembershipEligibility.MissingRoles); + var orchestrator = SetupOrchestrator(MembershipEligibility.MissingRoles); var cmd = orchestrator.GetCommand(); if (orchestrator.Database is not IMockOf dbMock) throw new InvalidOperationException("This test depends on the registered database implementing IMockOf"); - dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(n => n.Id == orchestrator.Subject.Id))).Throws(); + dbMock.Mock.Setup(n => n.GetOrCreateUserAsync(It.Is(guildUser => guildUser.Id == orchestrator.Subject.Id))).Throws(); var verifier = CreateVerifier() .WithDescription(Strings.Command_Eligibility_IneligibleText) diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index 77cba58..6550506 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -23,15 +23,15 @@ private static async Task SetupOrchestrator(PageCommandTestCon return orchestrator; } - - public static async IAsyncEnumerable GetTeams(TestOrchestrator orchestrator, PageTarget pageTarget) - { + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + private static async IAsyncEnumerable GetTeams(TestOrchestrator orchestrator, PageTarget pageTarget) + { var teamsConfig = orchestrator.Configuration.Teams.ToDictionary(n => n.InternalID, n => n); teamsConfig.Should().NotBeNull(); - var teamRefs = pageTarget.GetAttributesOfType()?.Select(n => n.InternalID) ?? - []; + var teamRefs = pageTarget.GetAttributesOfType()?.Select(n => n.InternalID) ?? []; foreach (var internalId in teamRefs) { @@ -41,6 +41,7 @@ public static async IAsyncEnumerable GetTeams(TestOrchestrator orchestrato yield return value; } } +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously private static Snowflake GetTeamRole(TestOrchestrator orchestrator, PageTarget owner) { diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs index e4044ec..d0988d0 100644 --- a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -1,5 +1,4 @@ using Discord; -using FluentAssertions; using InstarBot.Test.Framework; using InstarBot.Test.Framework.Models; using Moq; @@ -12,7 +11,7 @@ namespace InstarBot.Tests.Integration.Interactions; public static class ReportUserTests { - private static async Task SetupOrchestrator(ReportContext context) + private static TestOrchestrator SetupOrchestrator(ReportContext context) { var orchestrator = TestOrchestrator.Default; @@ -29,7 +28,7 @@ public static async Task ReportUser_WhenReportingNormally_ShouldNotifyStaff() .WithReason("This is a test report") .Build(); - var orchestrator = await SetupOrchestrator(context); + var orchestrator = SetupOrchestrator(context); var command = orchestrator.GetCommand(); var verifier = EmbedVerifier.Builder() @@ -42,7 +41,7 @@ public static async Task ReportUser_WhenReportingNormally_ShouldNotifyStaff() .Build(); // Act - await command.Object.HandleCommand(SetupMessageCommandMock(orchestrator, context)); + await command.Object.HandleCommand(await SetupMessageCommandMock(orchestrator, context)); await command.Object.ModalResponse(new ReportMessageModal { ReportReason = context.Reason @@ -50,8 +49,11 @@ await command.Object.ModalResponse(new ReportMessageModal // Assert command.VerifyResponse(Strings.Command_ReportUser_ReportSent, true); - - ((TestChannel) await orchestrator.Discord.GetChannel(orchestrator.Configuration.StaffAnnounceChannel)).VerifyEmbed(verifier, "{@}"); + + if (await orchestrator.Discord.GetChannel(orchestrator.Configuration.StaffAnnounceChannel) is not TestChannel testChannel) + throw new InvalidOperationException("Channel was not TestChannel"); + + testChannel.VerifyEmbed(verifier, "{@}"); } [Fact(DisplayName = "Report user function times out if cache expires")] @@ -62,11 +64,11 @@ public static async Task ReportUser_WhenReportingNormally_ShouldFail_IfNotComple .WithReason("This is a test report") .Build(); - var orchestrator = await SetupOrchestrator(context); + var orchestrator = SetupOrchestrator(context); var command = orchestrator.GetCommand(); // Act - await command.Object.HandleCommand(SetupMessageCommandMock(orchestrator, context)); + await command.Object.HandleCommand(await SetupMessageCommandMock(orchestrator, context)); ReportUserCommand.PurgeCache(); await command.Object.ModalResponse(new ReportMessageModal { @@ -77,9 +79,11 @@ await command.Object.ModalResponse(new ReportMessageModal command.VerifyResponse(Strings.Command_ReportUser_ReportExpired, true); } - private static IInstarMessageCommandInteraction SetupMessageCommandMock(TestOrchestrator orchestrator, ReportContext context) - { - TestChannel testChannel = (TestChannel) orchestrator.Guild.GetTextChannel(context.Channel); + private static async Task SetupMessageCommandMock(TestOrchestrator orchestrator, ReportContext context) + { + if (await orchestrator.Discord.GetChannel(context.Channel) is not TestChannel testChannel) + throw new InvalidOperationException("Channel was not TestChannel"); + var message = testChannel.AddMessage(orchestrator.Subject, "Naughty message"); var socketMessageDataMock = new Mock(); @@ -99,12 +103,7 @@ private static IInstarMessageCommandInteraction SetupMessageCommandMock(TestOrch private record ReportContext(Snowflake Channel, string Reason) { - public static ReportContextBuilder Builder() - { - return new ReportContextBuilder(); - } - - public Embed? ResultEmbed { get; set; } + public static ReportContextBuilder Builder() => new(); } private class ReportContextBuilder diff --git a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs index e7c1c32..4552f30 100644 --- a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs @@ -1,15 +1,10 @@ -using Amazon.SimpleSystemsManagement.Model; -using Discord; -using FluentAssertions; +using FluentAssertions; using InstarBot.Test.Framework; using InstarBot.Test.Framework.Services; -using Microsoft.Extensions.DependencyInjection; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; -using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.DynamoModels; -using PaxAndromeda.Instar.Services; using Xunit; using Assert = Xunit.Assert; diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs index 1b19bc0..44313e6 100644 --- a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -1,14 +1,9 @@ using Discord; using FluentAssertions; using InstarBot.Test.Framework; -using InstarBot.Test.Framework.Models; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Testing.Platform.Extensions.Messages; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Commands; -using PaxAndromeda.Instar.ConfigModels; -using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; using Xunit; @@ -16,71 +11,6 @@ namespace InstarBot.Tests.Integration.Interactions; public static class SetBirthdayCommandTests { - /*private static async Task<(IDatabaseService, Mock, InstarDynamicConfiguration)> SetupOrchestrator(SetBirthdayContext context, DateTime? timeOverride = null, bool throwError = false) - { - TestUtilities.SetupLogging(); - - var timeProvider = TimeProvider.System; - if (timeOverride is not null) - { - var timeProviderMock = new Mock(); - timeProviderMock.Setup(n => n.GetUtcNow()).Returns(new DateTimeOffset((DateTime) timeOverride)); - timeProvider = timeProviderMock.Object; - } - - var staffAnnounceChannelMock = new Mock(); - var birthdayAnnounceChannelMock = new Mock(); - context.StaffAnnounceChannel = staffAnnounceChannelMock; - context.BirthdayAnnounceChannel = birthdayAnnounceChannelMock; - - var ddbService = TestUtilities.GetServices().GetService(); - var cfgService = TestUtilities.GetDynamicConfiguration(); - var cfg = await cfgService.GetConfig(); - - var guildMock = new Mock(); - guildMock.Setup(n => n.GetTextChannel(cfg.StaffAnnounceChannel)).Returns(staffAnnounceChannelMock.Object); - guildMock.Setup(n => n.GetTextChannel(cfg.BirthdayConfig.BirthdayAnnounceChannel)).Returns(birthdayAnnounceChannelMock.Object); - - var testContext = new TestContext - { - UserID = context.UserID.ID, - Channels = - { - { cfg.StaffAnnounceChannel, staffAnnounceChannelMock.Object }, - { cfg.BirthdayConfig.BirthdayAnnounceChannel, birthdayAnnounceChannelMock.Object } - } - }; - - var discord = TestUtilities.SetupDiscordService(testContext); - if (discord is MockDiscordService mockDiscord) - { - mockDiscord.Guild = guildMock.Object; - } - - var birthdaySystem = new BirthdaySystem(cfgService, discord, ddbService, new MockMetricService(), timeProvider); - - if (throwError && ddbService is MockDatabaseService mockDDB) - mockDDB.Setup(n => n.GetOrCreateUserAsync(It.IsAny())).Throws(); - - var cmd = TestUtilities.SetupCommandMock(() => new SetBirthdayCommand(ddbService, TestUtilities.GetDynamicConfiguration(), new MockMetricService(), birthdaySystem, timeProvider), testContext); - - await cmd.Object.Context.User!.AddRoleAsync(cfg.NewMemberRoleID); - - cmd.Setup(n => n.Context.User!.GuildId).Returns(TestUtilities.GuildID); - - context.User = cmd.Object.Context.User!; - - cmd.Setup(n => n.Context.Guild).Returns(guildMock.Object); - - ((MockDatabaseService) ddbService).Register(InstarUserData.CreateFrom(cmd.Object.Context.User!)); - - ddbService.Should().NotBeNull(); - - return (ddbService, cmd, cfg); - }*/ - - private const ulong NewMemberRole = 796052052433698817ul; - private static async Task SetupOrchestrator(bool throwError = false) { var orchestrator = TestOrchestrator.Default; @@ -145,19 +75,12 @@ public static async Task SetBirthdayCommand_WithInvalidDate_ShouldReturnError(in await cmd.Object.SetBirthday((Month)month, day, year); // Assert - if (month is < 0 or > 12) - { - cmd.VerifyResponse(Strings.Command_SetBirthday_MonthsOutOfRange, true); - } - else - { - var date = new DateTime(year, month, 1); // there's always a 1st of the month - var daysInMonth = DateTime.DaysInMonth(year, month); - - // Assert - cmd.VerifyResponse(Strings.Command_SetBirthday_DaysInMonthOutOfRange, true); - } - } + cmd.VerifyResponse( + month is < 0 or > 12 + ? Strings.Command_SetBirthday_MonthsOutOfRange + // Assert + : Strings.Command_SetBirthday_DaysInMonthOutOfRange, true); + } [Fact(DisplayName = "Attempting to set a birthday in the future should emit an error message.")] public static async Task SetBirthdayCommand_WithDateInFuture_ShouldReturnError() diff --git a/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs index aceaef1..c4cca37 100644 --- a/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs +++ b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs @@ -2,16 +2,13 @@ using InstarBot.Test.Framework; using InstarBot.Test.Framework.Models; using InstarBot.Test.Framework.Services; -using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using PaxAndromeda.Instar; -using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Gaius; using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Services; using Xunit; -using static Microsoft.ApplicationInsights.MetricDimensionNames.TelemetryContext; namespace InstarBot.Tests.Integration.Services; @@ -41,19 +38,24 @@ private static async Task SetupOrchestrator(AutoMemberSystemCo if (context.PostedIntroduction) { - TestChannel introChannel = (TestChannel) await orchestrator.Discord.GetChannel(orchestrator.Configuration.AutoMemberConfig.IntroductionChannel); + if (await orchestrator.Discord.GetChannel(orchestrator.Configuration.AutoMemberConfig.IntroductionChannel) is not TestChannel introChannel) + throw new InvalidOperationException("Introduction channel was not TestChannel"); + introChannel.AddMessage(orchestrator.Subject, "Some introduction"); } for (var i = 0; i < context.MessagesLast24Hours; i++) channel.AddMessage(orchestrator.Subject, "Some text"); + var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); + if (dbUser is null) + throw new InvalidOperationException($"Database entry for user {orchestrator.Subject.Id} did not automatically populate"); + if (context.GrantedMembershipBefore) - { - var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); dbUser.Data.Position = InstarUserPosition.Member; - await dbUser.CommitAsync(); - } + + dbUser.Data.Joined = (orchestrator.TimeProvider.GetUtcNow() - TimeSpan.FromHours(context.FirstJoinTime)).UtcDateTime; + await dbUser.CommitAsync(); if (context.GaiusInhibited) { @@ -267,7 +269,6 @@ public static async Task AutoMemberSystem_UserWithGaiusWarning_ShouldNotBeGrante .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() - .HasBeenWarned() .WithMessages(100) .Build(); @@ -300,7 +301,6 @@ public static async Task AutoMemberSystem_UserWithGaiusCaselog_ShouldNotBeGrante .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() - .HasBeenPunished() .WithMessages(100) .Build(); @@ -334,7 +334,6 @@ public static async Task AutoMemberSystem_UserWithJoinAgeKick_ShouldBeGrantedMem .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() - .HasBeenPunished(true) .WithMessages(100) .Build(); @@ -396,7 +395,6 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe .Build(); var orchestrator = await SetupOrchestrator(context); - var ams = orchestrator.GetService(); // Act var service = orchestrator.Discord as TestDiscordService; @@ -409,7 +407,7 @@ public static async Task AutoMemberSystem_MemberThatRejoins_ShouldBeGrantedMembe // Assert context.AssertMember(orchestrator); - } + } [Fact(DisplayName = "The Dynamo record for a user should be updated when the user's username changes")] public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflectedInDynamo() @@ -419,7 +417,6 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(1)) - .FirstJoined(TimeSpan.FromDays(7)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() .HasBeenGrantedMembershipBefore() @@ -427,7 +424,6 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte .Build(); var orchestrator = await SetupOrchestrator(context); - var ams = orchestrator.GetService(); // Make sure the user is in the database await orchestrator.Database.CreateUserAsync(InstarUserData.CreateFrom(orchestrator.Subject)); @@ -451,22 +447,70 @@ public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldBeReflecte ddbUser.Data.Usernames.Should().Contain(n => n.Data != null && n.Data.Equals(newUsername, StringComparison.Ordinal)); } + [Fact(DisplayName = "The Dynamo record for a user should be updated when the user's username changes")] + public static async Task AutoMemberSystem_MemberMetadataUpdated_ShouldKickWithForbiddenRole() + { + // Arrange + const string newUsername = "fred"; + + var context = AutoMemberSystemContext.Builder() + .Joined(TimeSpan.FromHours(1)) + .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) + .HasPostedIntroduction() + .HasBeenGrantedMembershipBefore() + .WithMessages(100) + .Build(); + + var orchestrator = await SetupOrchestrator(context); + + // Make sure the user is in the database + await orchestrator.Database.CreateUserAsync(InstarUserData.CreateFrom(orchestrator.Subject)); + + // Act + var mds = (TestDiscordService) orchestrator.Discord; + + var newUser = orchestrator.Subject.Clone(); + newUser.Username = newUsername; + + var autoKickRole = orchestrator.Configuration.AutoKickRoles?.First() ?? throw new BadStateException("This test expects AutoKickRoles to be set"); + await newUser.AddRoleAsync(autoKickRole); + + newUser.RoleIds.Should().Contain(autoKickRole); + + await mds.TriggerUserUpdated(new UserUpdatedEventArgs(orchestrator.Subject.Id, orchestrator.Subject, newUser)); + + // Assert + var ddbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); + + ddbUser.Should().NotBeNull(); + ddbUser.Data.Username.Should().Be(newUsername); + + ddbUser.Data.Usernames.Should().NotBeNull(); + ddbUser.Data.Usernames.Count.Should().Be(2); + ddbUser.Data.Usernames.Should().Contain(n => n.Data != null && n.Data.Equals(newUsername, StringComparison.Ordinal)); + + newUser.Mock.Verify(n => n.KickAsync(It.IsAny())); + } + [Fact(DisplayName = "A user should be created in DynamoDB if they're eligible for membership but missing in DDB")] public static async Task AutoMemberSystem_MemberEligibleButMissingInDDB_ShouldBeCreatedAndGrantedMembership() { - // Arrange var context = AutoMemberSystemContext.Builder() .Joined(TimeSpan.FromHours(36)) .SetRoles(NewMember, Transfemme, TwentyOnePlus, SheHer) .HasPostedIntroduction() .WithMessages(100) - .SuppressDDBEntry() .Build(); var orchestrator = await SetupOrchestrator(context); var ams = orchestrator.GetService(); + if (orchestrator.Database is not TestDatabaseService tbs) + throw new InvalidOperationException("Database was not TestDatabaseService"); + + tbs.DeleteUser(orchestrator.Subject.Id); + // Act await ams.RunAsync(); @@ -481,7 +525,6 @@ private record AutoMemberSystemContext( int MessagesLast24Hours, int FirstJoinTime, bool GrantedMembershipBefore, - bool SuppressDDBEntry, bool GaiusInhibited) { public static AutoMemberSystemContextBuilder Builder() => new(); @@ -514,12 +557,8 @@ private class AutoMemberSystemContextBuilder private bool _postedIntroduction; private int _messagesLast24Hours; private bool _gaiusAvailable = true; - private bool _gaiusPunished; - private bool _joinAgeKick; - private bool _gaiusWarned; - private int _firstJoinTime; private bool _grantedMembershipBefore; - private bool _suppressDDB; + private int _firstJoinTime; public AutoMemberSystemContextBuilder Joined(TimeSpan timeAgo) @@ -551,21 +590,7 @@ public AutoMemberSystemContextBuilder InhibitGaius() _gaiusAvailable = false; return this; } - - public AutoMemberSystemContextBuilder HasBeenPunished(bool isJoinAgeKick = false) - { - _gaiusPunished = true; - _joinAgeKick = isJoinAgeKick; - - return this; - } - - public AutoMemberSystemContextBuilder HasBeenWarned() - { - _gaiusWarned = true; - return this; - } - + public AutoMemberSystemContextBuilder FirstJoined(TimeSpan hoursAgo) { _firstJoinTime = (int) Math.Round(hoursAgo.TotalHours); @@ -578,12 +603,6 @@ public AutoMemberSystemContextBuilder HasBeenGrantedMembershipBefore() return this; } - public AutoMemberSystemContextBuilder SuppressDDBEntry() - { - _suppressDDB = true; - return this; - } - public AutoMemberSystemContext Build() { return new AutoMemberSystemContext( @@ -593,7 +612,6 @@ public AutoMemberSystemContext Build() _messagesLast24Hours, _firstJoinTime, _grantedMembershipBefore, - _suppressDDB, _gaiusAvailable); } } diff --git a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs index 3ced526..f1accf2 100644 --- a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs +++ b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs @@ -3,7 +3,6 @@ using InstarBot.Test.Framework.Models; using Moq; using PaxAndromeda.Instar; -using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.Services; using Xunit; using Metric = PaxAndromeda.Instar.Metrics.Metric; diff --git a/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs b/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs new file mode 100644 index 0000000..941bc4d --- /dev/null +++ b/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs @@ -0,0 +1,207 @@ +using Discord; +using FluentAssertions; +using InstarBot.Test.Framework; +using InstarBot.Test.Framework.Models; +using Moq; +using PaxAndromeda.Instar; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Metrics; +using PaxAndromeda.Instar.Services; +using Xunit; + +namespace InstarBot.Tests.Integration.Services; + +public static class NotificationSystemTests +{ + private static async Task SetupOrchestrator() + { + return await TestOrchestrator.Builder + .WithService() + .Build(); + } + + private static Notification CreateNotification(TestOrchestrator orchestrator) + { + return new Notification + { + Actor = orchestrator.Actor.Id, + Channel = orchestrator.Configuration.StaffAnnounceChannel, + Date = (orchestrator.TimeProvider.GetUtcNow() - TimeSpan.FromMinutes(5)).UtcDateTime, + GuildID = orchestrator.GuildID, + Priority = NotificationPriority.Normal, + Subject = "Some test subject", + Targets = [ + new NotificationTarget { Id = orchestrator.Configuration.StaffRoleID, Type = NotificationTargetType.Role } + ], + Data = new NotificationData + { + Message = "This is a test notification", + Fields = [ + new NotificationEmbedField + { + Name = "Some field", + Value = "Some value" + } + ] + } + }; + } + + private static EmbedVerifier CreateVerifierFromNotification(TestOrchestrator orchestrator, Notification notification) + { + var verifier = EmbedVerifier.Builder() + .WithAuthorName(orchestrator.Actor.Username) + .WithTitle(notification.Subject) + .WithDescription(notification.Data.Message); + + if (notification.Data.Fields is not null) + verifier = notification.Data.Fields.Aggregate(verifier, (current, field) => current.WithField(field.Name, field.Value)); + + return verifier.Build(); + } + + [Fact] + public static async Task NotificationSystem_ShouldEmitNothing_WhenNoNotificationsArePresent() + { + // Arrange + var orchestrator = await SetupOrchestrator(); + var service = orchestrator.GetService(); + + // Act + await service.RunAsync(); + + // Assert + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsSent).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsFailed).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_MalformedNotification).Sum().Should().Be(0); + } + + [Fact] + public static async Task NotificationSystem_ShouldPostEmbed_WithPendingNotification() + { + // Arrange + var orchestrator = await SetupOrchestrator(); + var service = orchestrator.GetService(); + + if (orchestrator.Actor is not TestGuildUser tgu) + throw new InvalidOperationException("Actor is not a TestGuildUser"); + + tgu.Username = "username"; + + var notification = CreateNotification(orchestrator); + var verifier = CreateVerifierFromNotification(orchestrator, notification); + var channel = orchestrator.GetChannel(orchestrator.Configuration.StaffAnnounceChannel); + + // Populate the database + await orchestrator.Database.CreateNotificationAsync(notification); + (await orchestrator.Database.GetPendingNotifications()).Count.Should().Be(1); + + // Act + await service.RunAsync(); + + // Assert + channel.VerifyEmbed(verifier, "<@&{0}>"); + + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsSent).Sum().Should().Be(1); + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsFailed).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_MalformedNotification).Sum().Should().Be(0); + + (await orchestrator.Database.GetPendingNotifications()).Count.Should().Be(0); + } + + [Fact] + public static async Task NotificationSystem_ShouldDoNothing_WithNotificationPendingForAnotherGuild() + { + // Arrange + var orchestrator = await SetupOrchestrator(); + var service = orchestrator.GetService(); + + var notification = CreateNotification(orchestrator); + notification.GuildID = Snowflake.Generate(); + + // Populate the database + await orchestrator.Database.CreateNotificationAsync(notification); + (await orchestrator.Database.GetPendingNotifications()).Count.Should().Be(0); + + // Act + await service.RunAsync(); + + // Assert + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsSent).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsFailed).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_MalformedNotification).Sum().Should().Be(0); + + (await orchestrator.Database.GetPendingNotifications()).Count.Should().Be(0); + } + + [Fact] + public static async Task NotificationSystem_ShouldEmitError_WithBadChannel() + { + // Arrange + var orchestrator = await SetupOrchestrator(); + var service = orchestrator.GetService(); + + var notification = CreateNotification(orchestrator); + // Override the channel + notification.Channel = new Snowflake(Snowflake.Epoch); + + + // Populate the database + await orchestrator.Database.CreateNotificationAsync(notification); + (await orchestrator.Database.GetPendingNotifications()).Count.Should().Be(1); + + // Act + await service.RunAsync(); + + // Assert + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsSent).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsFailed).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_MalformedNotification).Sum().Should().Be(1); + + (await orchestrator.Database.GetPendingNotifications()).Count.Should().Be(0); + } + + [Fact] + public static async Task NotificationSystem_ShouldEmitError_WithFailedMessageSend() + { + // Arrange + var orchestrator = await SetupOrchestrator(); + var service = orchestrator.GetService(); + + var notification = CreateNotification(orchestrator); + + if (orchestrator.GetChannel(orchestrator.Configuration.StaffAnnounceChannel) is not IMockOf textChannelMock) + throw new InvalidOperationException($"Expected channel {orchestrator.Configuration.StaffAnnounceChannel.ID} to be IMockOf"); + + textChannelMock.Mock.Setup(n => n.SendMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())).Throws(); + + + // Populate the database + await orchestrator.Database.CreateNotificationAsync(notification); + (await orchestrator.Database.GetPendingNotifications()).Count.Should().Be(1); + + // Act + await service.RunAsync(); + + // Assert + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsSent).Sum().Should().Be(0); + orchestrator.Metrics.GetMetricValues(Metric.Notification_NotificationsFailed).Sum().Should().Be(1); + orchestrator.Metrics.GetMetricValues(Metric.Notification_MalformedNotification).Sum().Should().Be(0); + + var pendingNotifications = await orchestrator.Database.GetPendingNotifications(); + pendingNotifications.Count.Should().Be(1); + pendingNotifications.First().Should().NotBeNull(); + pendingNotifications.First().Data.SendAttempts.Should().Be(1); + } +} \ No newline at end of file diff --git a/InstarBot.Tests.Integration/TestTest.cs b/InstarBot.Tests.Integration/TestTest.cs deleted file mode 100644 index 73c571a..0000000 --- a/InstarBot.Tests.Integration/TestTest.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Discord; -using FluentAssertions; -using InstarBot.Test.Framework; -using InstarBot.Test.Framework.Models; -using Moq; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Commands; -using PaxAndromeda.Instar.Services; -using Xunit; - -namespace InstarBot.Tests.Integration; - -public class TestTest -{ - private const ulong NewMemberRole = 796052052433698817ul; - - private static async Task<(TestOrchestrator, Mock)> SetupMocks2(DateTimeOffset? timeOverride = null, bool throwError = false) - { - var orchestrator = await TestOrchestrator.Builder - .WithTime(timeOverride) - .WithService() - .Build(); - - await orchestrator.Actor.AddRoleAsync(NewMemberRole); - - // yoooo, this is going to be so nice if this all works - var cmd = orchestrator.GetCommand(); - - if (throwError) - { - if (orchestrator.GetService() is not IMockOf ddbService) - throw new InvalidOperationException("IDatabaseService was not mocked correctly."); - - ddbService.Mock.Setup(n => n.GetOrCreateUserAsync(It.IsAny())).Throws(); - } - - return (orchestrator, cmd); - } - - [Fact] - public async Task Test() - { - var (orchestrator, mock) = await SetupMocks2(DateTimeOffset.UtcNow); - - var guildUser = orchestrator.Actor as IGuildUser; - - guildUser.RoleIds.Should().Contain(NewMemberRole); - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/MockExtensions.cs b/InstarBot.Tests.Orchestrator/MockExtensions.cs index d29e7fe..f6c3854 100644 --- a/InstarBot.Tests.Orchestrator/MockExtensions.cs +++ b/InstarBot.Tests.Orchestrator/MockExtensions.cs @@ -1,4 +1,5 @@ using Discord; +using Discord.Interactions; using InstarBot.Tests; using Moq; using Moq.Protected; @@ -38,7 +39,7 @@ public void VerifyMessage(string format, bool partial = false) /// /// Verifies that the command responded to the user with an embed that satisfies the specified . /// - /// The type of command. Must implement . + /// The type of command. Must implement . /// An instance to verify against. /// An optional message format, if present. Defaults to null. /// An optional flag indicating whether partial matches are acceptable. Defaults to false. diff --git a/InstarBot.Tests.Orchestrator/Models/TestChannel.cs b/InstarBot.Tests.Orchestrator/Models/TestChannel.cs index d5edfc0..d52cee2 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestChannel.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestChannel.cs @@ -3,7 +3,6 @@ using Moq; using PaxAndromeda.Instar; using System.Diagnostics.CodeAnalysis; -using InvalidOperationException = Amazon.CloudWatchLogs.Model.InvalidOperationException; using MessageProperties = Discord.MessageProperties; #pragma warning disable CS1998 @@ -12,23 +11,17 @@ namespace InstarBot.Test.Framework.Models; [SuppressMessage("ReSharper", "ReplaceAutoPropertyWithComputedProperty")] -public sealed class TestChannel : IMockOf, ITextChannel +public sealed class TestChannel (Snowflake id) : IMockOf, ITextChannel { - public ulong Id { get; } - public DateTimeOffset CreatedAt { get; } + public ulong Id { get; } = id; + public DateTimeOffset CreatedAt { get; } = id.Time; - public Mock Mock { get; } = new(); + public Mock Mock { get; } = new(); private readonly Dictionary _messages = new(); public IEnumerable Messages => _messages.Values; - public TestChannel(Snowflake id) - { - Id = id; - CreatedAt = id.Time; - } - public void VerifyMessage(string format, bool partial = false) { Mock.Verify(c => c.SendMessageAsync( @@ -250,7 +243,8 @@ public Task SendFilesAsync(IEnumerable attachments public Task ModifyAsync(Action func, RequestOptions options = null) { - return ((IGuildChannel)Mock).ModifyAsync(func, options); + // ReSharper disable once SuspiciousTypeConversion.Global + return ((IGuildChannel) Mock).ModifyAsync(func, options); } public OverwritePermissions? GetPermissionOverwrite(IRole role) @@ -305,11 +299,13 @@ Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode , RequestO IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode , RequestOptions options ) { + // ReSharper disable once SuspiciousTypeConversion.Global return ((IChannel)Mock).GetUsersAsync(mode, options); } Task IChannel.GetUserAsync(ulong id, CacheMode mode , RequestOptions options ) { + // ReSharper disable once SuspiciousTypeConversion.Global return ((IChannel)Mock).GetUserAsync(id, mode, options); } diff --git a/InstarBot.Tests.Orchestrator/Models/TestGuild.cs b/InstarBot.Tests.Orchestrator/Models/TestGuild.cs index 54edafd..57202e2 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestGuild.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestGuild.cs @@ -20,9 +20,9 @@ public IEnumerable TextChannels public List Users { get; init; } = []; - public virtual ITextChannel GetTextChannel(ulong channelId) + public virtual ITextChannel? GetTextChannel(ulong channelId) { - return TextChannels.First(n => n.Id.Equals(channelId)); + return TextChannels.FirstOrDefault(n => n.Id.Equals(channelId)); } public virtual IRole? GetRole(Snowflake roleId) diff --git a/InstarBot.Tests.Orchestrator/Models/TestGuildUser.cs b/InstarBot.Tests.Orchestrator/Models/TestGuildUser.cs index 6553d12..4774b75 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestGuildUser.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestGuildUser.cs @@ -1,6 +1,5 @@ using System.Diagnostics.CodeAnalysis; using Discord; -using InstarBot.Tests; using Moq; using PaxAndromeda.Instar; @@ -12,9 +11,9 @@ namespace InstarBot.Test.Framework.Models; [SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] public class TestGuildUser : TestUser, IMockOf, IGuildUser { - public Mock Mock { get; } = new(); + public new Mock Mock { get; } = new(); - private HashSet _roleIds = [ ]; + private HashSet _roleIds; public TestGuildUser() : this(Snowflake.Generate(), [ ]) { } @@ -134,19 +133,21 @@ public Task RemoveTimeOutAsync(RequestOptions options = null) } public DateTimeOffset? JoinedAt { get; init; } - public string DisplayName { get; set; } = null!; - public string Nickname { get; set; } = null!; + public string DisplayName => Nickname ?? Username; + // ReSharper disable once PropertyCanBeMadeInitOnly.Global + public string? Nickname { get; set; } public string DisplayAvatarId { get; set; } = null!; public string GuildAvatarId { get; set; } = null!; public GuildPermissions GuildPermissions { get; set; } public IGuild Guild { get; set; } = null!; - public ulong GuildId { get; internal set; } = 0; + public ulong GuildId { get; internal set; } public DateTimeOffset? PremiumSince { get; set; } public IReadOnlyCollection RoleIds { get => _roleIds.AsReadOnly(); - set => _roleIds = new HashSet(value); + // ReSharper disable once PropertyCanBeMadeInitOnly.Global + set => _roleIds = [..value]; } public bool? IsPending { get; set; } diff --git a/InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs b/InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs index 1481f5a..ad90861 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestInteractionContext.cs @@ -1,26 +1,21 @@ using Discord; -using FluentAssertions; +using JetBrains.Annotations; using Moq; using PaxAndromeda.Instar; -using System; -using System.Threading; using PaxAndromeda.Instar.Services; namespace InstarBot.Test.Framework.Models; public class TestInteractionContext : InstarContext, IMockOf { - private readonly TestOrchestrator _orchestrator; public Mock Mock { get; } = new(); - public IDiscordClient Client => Mock.Object.Client; + public new IDiscordClient Client => Mock.Object.Client; public TestInteractionContext(TestOrchestrator orchestrator, Snowflake actorId, Snowflake channelId) { - _orchestrator = orchestrator; - var discordService = orchestrator.GetService(); - if (discordService.GetUser(actorId) is not { } actor) + if (discordService.GetUser(actorId) is null) throw new InvalidOperationException("Actor needs to be registered before creating an interaction context"); @@ -35,37 +30,8 @@ public TestInteractionContext(TestOrchestrator orchestrator, Snowflake actorId, Mock.SetupGet(static n => n.Guild).Returns(orchestrator.GetService().GetGuild()); } - private Mock SetupGuildMock() - { - var discordService = _orchestrator.GetService(); - - var guildMock = new Mock(); - guildMock.Setup(n => n.Id).Returns(_orchestrator.GuildID); - guildMock.Setup(n => n.GetTextChannel(It.IsAny())) - .Returns((ulong x) => discordService.GetChannel(x) as ITextChannel); - - return guildMock; - } - - private TestGuildUser SetupUser(IGuildUser user) - { - return new TestGuildUser(user.Id, user.RoleIds.Select(id => new Snowflake(id))); - } - - private TestChannel SetupChannel(Snowflake channelId) - { - var discordService = _orchestrator.GetService(); - var channel = discordService.GetChannel(channelId).Result; - - if (channel is not TestChannel testChannel) - throw new InvalidOperationException("Channel must be registered before use in an interaction context"); - - return testChannel; - } - - protected internal override IInstarGuild Guild => Mock.Object.Guild; - protected internal override IGuildChannel Channel => Mock.Object.Channel; - protected internal override IGuildUser User => Mock.Object.User; - public IDiscordInteraction Interaction { get; } = new Mock().Object; + protected internal override IGuildChannel? Channel => Mock.Object.Channel; + protected internal override IGuildUser? User => Mock.Object.User; + [UsedImplicitly] public new IDiscordInteraction Interaction { get; } = new Mock().Object; } \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Models/TestMessage.cs b/InstarBot.Tests.Orchestrator/Models/TestMessage.cs index 71c82ed..aedeff2 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestMessage.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestMessage.cs @@ -1,11 +1,13 @@ using Discord; +using JetBrains.Annotations; using Moq; using PaxAndromeda.Instar; using MessageProperties = Discord.MessageProperties; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. namespace InstarBot.Test.Framework.Models; -public sealed class TestMessage : IMockOf, IUserMessage, IMessage +public sealed class TestMessage : IMockOf, IUserMessage { public Mock Mock { get; } = new(); @@ -19,6 +21,7 @@ internal TestMessage(IUser user, string message) Content = message; } + // ReSharper disable UnusedParameter.Local public TestMessage(string text, bool isTTS, Embed? embed, RequestOptions? options, AllowedMentions? allowedMentions, MessageReference? messageReference, MessageComponent? components, ISticker[]? stickers, Embed[]? embeds, MessageFlags? flags, PollProperties? poll) { Id = Snowflake.Generate(); @@ -27,6 +30,8 @@ public TestMessage(string text, bool isTTS, Embed? embed, RequestOptions? option Flags = flags; var embedList = new List(); + if (embedList == null) + throw new ArgumentNullException(nameof(embedList)); if (embed is not null) embedList.Add(embed); @@ -73,7 +78,7 @@ public IAsyncEnumerable> GetReactionUsersAsync(IEmote public MessageType Type => default; public MessageSource Source => default; - public bool IsTTS { get; set; } + public bool IsTTS { get; } public bool IsPinned => false; public bool IsSuppressed => false; @@ -82,7 +87,7 @@ public IAsyncEnumerable> GetReactionUsersAsync(IEmote public string CleanContent => null!; public DateTimeOffset Timestamp { get; } public DateTimeOffset? EditedTimestamp => null; - public IMessageChannel Channel { get; set; } = null!; + public IMessageChannel Channel { get; init; } = null!; public IUser Author { get; } public IThreadChannel Thread => null!; public IReadOnlyCollection Attachments => null!; @@ -93,12 +98,12 @@ public IAsyncEnumerable> GetReactionUsersAsync(IEmote public IReadOnlyCollection MentionedUserIds => null!; public MessageActivity Activity => null!; public MessageApplication Application => null!; - public MessageReference Reference { get; set; } + public MessageReference? Reference { get; } public IReadOnlyDictionary Reactions => null!; public IReadOnlyCollection Components => null!; public IReadOnlyCollection Stickers => null!; - public MessageFlags? Flags { get; set; } + public MessageFlags? Flags { get; } public IMessageInteraction Interaction => null!; public MessageRoleSubscriptionData RoleSubscriptionData => null!; @@ -143,11 +148,11 @@ public IAsyncEnumerable> GetPollAnswerVotersAsync(uin return Mock.Object.GetPollAnswerVotersAsync(answerId, limit, afterId, options); } - public MessageResolvedData ResolvedData { get; set; } - public IUserMessage ReferencedMessage { get; set; } - public IMessageInteractionMetadata InteractionMetadata { get; set; } - public IReadOnlyCollection ForwardedMessages { get; set; } - public Poll? Poll { get; set; } + public MessageResolvedData ResolvedData { get; [UsedImplicitly] set; } + public IUserMessage ReferencedMessage { get; [UsedImplicitly] set; } + public IMessageInteractionMetadata InteractionMetadata { get; [UsedImplicitly] set; } + public IReadOnlyCollection ForwardedMessages { get; [UsedImplicitly] set; } + public Poll? Poll { get; [UsedImplicitly] set; } public Task DeleteAsync(RequestOptions? options = null) { return Mock.Object.DeleteAsync(options); diff --git a/InstarBot.Tests.Orchestrator/Models/TestRole.cs b/InstarBot.Tests.Orchestrator/Models/TestRole.cs index e9f8eed..884da80 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestRole.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestRole.cs @@ -39,7 +39,7 @@ public int CompareTo(IRole? other) public bool IsHoisted { get; set; } = false; public bool IsManaged { get; set; } = false; public bool IsMentionable { get; set; } = false; - public string Name { get; set; } = null!; + public string Name { get; init; } = null!; public string Icon { get; set; } = null!; public Emoji Emoji { get; set; } = null!; public GuildPermissions Permissions { get; set; } = default!; diff --git a/InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs b/InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs index 377aa2f..95852e7 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestSocketUser.cs @@ -1,9 +1,11 @@ using Discord; using Discord.WebSocket; +using JetBrains.Annotations; using Moq; namespace InstarBot.Test.Framework.Models; +[UsedImplicitly] public class TestSocketUser : IMockOf { public Mock Mock { get; } = new(); diff --git a/InstarBot.Tests.Orchestrator/Models/TestUser.cs b/InstarBot.Tests.Orchestrator/Models/TestUser.cs index 3678d2f..9e69e83 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestUser.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestUser.cs @@ -2,6 +2,7 @@ using Discord; using Moq; using PaxAndromeda.Instar; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. namespace InstarBot.Test.Framework.Models; @@ -63,7 +64,7 @@ public string GetDisplayAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort return Mock.Object.GetDisplayAvatarUrl(format, size); } - public Task CreateDMChannelAsync(RequestOptions options = null) + public Task CreateDMChannelAsync(RequestOptions? options = null) { return Task.FromResult(DMChannelMock.Object); } @@ -80,7 +81,7 @@ public string GetAvatarDecorationUrl() public bool IsWebhook { get; set; } public string Username { get; set; } public UserProperties? PublicFlags { get; set; } - public string GlobalName { get; set; } + public string GlobalName { get; init; } public string AvatarDecorationHash { get; set; } public ulong? AvatarDecorationSkuId { get; set; } public PrimaryGuild? PrimaryGuild { get; set; } diff --git a/InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs b/InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs index d08c8b5..eca1a7f 100644 --- a/InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs +++ b/InstarBot.Tests.Orchestrator/Services/TestAutoMemberSystem.cs @@ -1,4 +1,5 @@ using Discord; +using JetBrains.Annotations; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.ConfigModels; @@ -6,6 +7,7 @@ namespace InstarBot.Test.Framework.Services; +[UsedImplicitly] public class TestAutoMemberSystem : IMockOf, IAutoMemberSystem { public Mock Mock { get; } = new(); diff --git a/InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs b/InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs index 4763b93..3e65d2d 100644 --- a/InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs +++ b/InstarBot.Tests.Orchestrator/Services/TestBirthdaySystem.cs @@ -1,9 +1,11 @@ using Discord; +using JetBrains.Annotations; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Services; namespace InstarBot.Test.Framework.Services; +[UsedImplicitly] public class TestBirthdaySystem : IBirthdaySystem { public Task Start() diff --git a/InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs b/InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs index 63fd6b2..c71d486 100644 --- a/InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs +++ b/InstarBot.Tests.Orchestrator/Services/TestDatabaseService.cs @@ -1,57 +1,61 @@ -using System.Diagnostics.CodeAnalysis; -using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DataModel; using Discord; +using JetBrains.Annotations; using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; +using System.Diagnostics.CodeAnalysis; namespace InstarBot.Test.Framework.Services; +[UsedImplicitly] public class TestDatabaseService : IMockOf, IDatabaseService { + private readonly IDynamicConfigService _dynamicConfig; private readonly TimeProvider _timeProvider; private readonly Dictionary _userDataTable; private readonly Dictionary _notifications; - private readonly Mock _contextMock; // Although we don't mock anything here, we use this to // throw exceptions if they're configured. public Mock Mock { get; } = new(); - public TestDatabaseService(TimeProvider timeProvider) + public Mock ContextMock { get; } = new(); + + public TestDatabaseService(IDynamicConfigService dynamicConfig, TimeProvider timeProvider) { + _dynamicConfig = dynamicConfig; _timeProvider = timeProvider; _userDataTable = new Dictionary(); _notifications = []; - _contextMock = new Mock(); SetupContextMock(_userDataTable, data => data.UserID!); SetupContextMock(_notifications, notif => notif.Date); } - private void SetupContextMock(Dictionary mapPointer, Func keySelector) + private void SetupContextMock(Dictionary mapPointer, Func keySelector) where V : notnull { - _contextMock.Setup(n => n.DeleteAsync(It.IsAny())).Callback((T data, CancellationToken _) => + ContextMock.Setup(n => n.DeleteAsync(It.IsAny())).Callback((T data, CancellationToken _) => { var key = keySelector(data); mapPointer.Remove(key); }); - _contextMock.Setup(n => n.SaveAsync(It.IsAny())).Callback((T data, CancellationToken _) => + ContextMock.Setup(n => n.SaveAsync(It.IsAny())).Callback((T data, CancellationToken _) => { var key = keySelector(data); mapPointer[key] = data; }); } - public Task?> GetUserAsync(Snowflake snowflake) + public async Task?> GetUserAsync(Snowflake snowflake) { - Mock.Object.GetUserAsync(snowflake); + await Mock.Object.GetUserAsync(snowflake); return !_userDataTable.TryGetValue(snowflake, out var userData) - ? Task.FromResult>(null) - : Task.FromResult(new InstarDatabaseEntry(_contextMock.Object, userData)); + ? null + : new InstarDatabaseEntry(ContextMock.Object, userData); } public Task> GetOrCreateUserAsync(IGuildUser user) @@ -64,7 +68,7 @@ public Task> GetOrCreateUserAsync(IGuildUser _userDataTable[user.Id] = userData; } - return Task.FromResult(new InstarDatabaseEntry(_contextMock.Object, userData)); + return Task.FromResult(new InstarDatabaseEntry(ContextMock.Object, userData)); } [SuppressMessage("ReSharper", "PossibleMultipleEnumeration", Justification = "Doesn't actually enumerate multiple times. First 'enumeration' is a mock which does nothing.")] @@ -79,7 +83,7 @@ public Task>> GetBatchUsersAsync(IEnume returnList.Add(userData); } - return Task.FromResult(returnList.Select(data => new InstarDatabaseEntry(_contextMock.Object, data)).ToList()); + return Task.FromResult(returnList.Select(data => new InstarDatabaseEntry(ContextMock.Object, data)).ToList()); } public Task CreateUserAsync(InstarUserData data) @@ -105,20 +109,22 @@ public Task>> GetUsersByBirthday(DateTi return userBirthdateThisYear >= startUtc && userBirthdateThisYear <= endUtc; }).ToList(); - return Task.FromResult(matchedUsers.Select(data => new InstarDatabaseEntry(_contextMock.Object, data)).ToList()); + return Task.FromResult(matchedUsers.Select(data => new InstarDatabaseEntry(ContextMock.Object, data)).ToList()); } - public Task>> GetPendingNotifications() + public async Task>> GetPendingNotifications() { - Mock.Object.GetPendingNotifications(); + await Mock.Object.GetPendingNotifications(); var currentTimeUtc = _timeProvider.GetUtcNow(); + var cfg = await _dynamicConfig.GetConfig(); + var pendingNotifications = _notifications.Values - .Where(notification => notification.Date <= currentTimeUtc) + .Where(notification => notification.Date <= currentTimeUtc && notification.GuildID == cfg.TargetGuild) .ToList(); - return Task.FromResult(pendingNotifications.Select(data => new InstarDatabaseEntry(_contextMock.Object, data)).ToList()); + return pendingNotifications.Select(data => new InstarDatabaseEntry(ContextMock.Object, data)).ToList(); } public Task> CreateNotificationAsync(Notification notification) @@ -126,6 +132,26 @@ public Task> CreateNotificationAsync(Notificat Mock.Object.CreateNotificationAsync(notification); _notifications[notification.Date] = notification; - return Task.FromResult(new InstarDatabaseEntry(_contextMock.Object, notification)); + return Task.FromResult(new InstarDatabaseEntry(ContextMock.Object, notification)); + } + + public Task>> GetNotificationsByTypeAndReferenceUser(NotificationType type, Snowflake userId) + { + return Task.FromResult( + _notifications.Values + .Where(n => n.Type == type && n.ReferenceUser == userId) + .Select(n => new InstarDatabaseEntry(ContextMock.Object, n)) + .ToList() + ); + } + + public List GetAllNotifications() + { + return _notifications.Values.ToList(); + } + + public void DeleteUser(Snowflake userId) + { + _userDataTable.Remove(userId); } } \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs b/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs index 29d13e6..0ecee3e 100644 --- a/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs +++ b/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs @@ -4,7 +4,6 @@ using PaxAndromeda.Instar; using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Services; -using System.Threading.Channels; namespace InstarBot.Test.Framework.Services; @@ -75,9 +74,9 @@ public Task> GetAllUsers() return Task.FromResult(_guild.Users.AsEnumerable()); } - public Task GetChannel(Snowflake channelId) + public Task GetChannel(Snowflake channelId) { - return Task.FromResult(_guild.GetTextChannel(channelId)); + return Task.FromResult(_guild.GetTextChannel(channelId)); } public IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime) diff --git a/InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs b/InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs index d45d3f3..7b57445 100644 --- a/InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs +++ b/InstarBot.Tests.Orchestrator/Services/TestGaiusAPIService.cs @@ -1,15 +1,19 @@ using InstarBot.Test.Framework.Models; +using JetBrains.Annotations; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Gaius; using PaxAndromeda.Instar.Services; namespace InstarBot.Test.Framework.Services; -public sealed class TestGaiusAPIService : IGaiusAPIService +[UsedImplicitly] +public sealed class TestGaiusAPIService ( + Dictionary> warnings, + Dictionary> caselogs, + bool inhibit = false) + : IGaiusAPIService { - private readonly Dictionary> _warnings; - private readonly Dictionary> _caselogs; - private bool _inhibit; + private bool _inhibit = inhibit; public void Inhibit() { @@ -18,18 +22,18 @@ public void Inhibit() public void AddWarning(TestGuildUser user, Warning warning) { - if (!_warnings.ContainsKey(user.Id)) - _warnings[user.Id] = []; + if (!warnings.ContainsKey(user.Id)) + warnings[user.Id] = []; - _warnings[user.Id].Add(warning); + warnings[user.Id].Add(warning); } public void AddCaselog(TestGuildUser user, Caselog caselog) { - if (!_caselogs.ContainsKey(user.Id)) - _caselogs[user.Id] = [ ]; + if (!caselogs.ContainsKey(user.Id)) + caselogs[user.Id] = [ ]; - _caselogs[user.Id].Add(caselog); + caselogs[user.Id].Add(caselog); } public TestGaiusAPIService() : @@ -39,15 +43,6 @@ public TestGaiusAPIService(bool inhibit) : this(new Dictionary>(), new Dictionary>(), inhibit) { } - public TestGaiusAPIService(Dictionary> warnings, - Dictionary> caselogs, - bool inhibit = false) - { - _warnings = warnings; - _caselogs = caselogs; - _inhibit = inhibit; - } - public void Dispose() { // do nothing @@ -55,22 +50,22 @@ public void Dispose() public Task> GetAllWarnings() { - return Task.FromResult>(_warnings.Values.SelectMany(list => list).ToList()); + return Task.FromResult>(warnings.Values.SelectMany(list => list).ToList()); } public Task> GetAllCaselogs() { - return Task.FromResult>(_caselogs.Values.SelectMany(list => list).ToList()); + return Task.FromResult>(caselogs.Values.SelectMany(list => list).ToList()); } public Task> GetWarningsAfter(DateTime dt) { - return Task.FromResult>(from list in _warnings.Values from item in list where item.WarnDate > dt select item); + return Task.FromResult>(from list in warnings.Values from item in list where item.WarnDate > dt select item); } public Task> GetCaselogsAfter(DateTime dt) { - return Task.FromResult>(from list in _caselogs.Values from item in list where item.Date > dt select item); + return Task.FromResult>(from list in caselogs.Values from item in list where item.Date > dt select item); } public Task?> GetWarnings(Snowflake userId) @@ -78,7 +73,7 @@ public Task> GetCaselogsAfter(DateTime dt) if (_inhibit) return Task.FromResult?>(null); - return !_warnings.TryGetValue(userId, out var warning) + return !warnings.TryGetValue(userId, out var warning) ? Task.FromResult?>([]) : Task.FromResult?>(warning); } @@ -88,7 +83,7 @@ public Task> GetCaselogsAfter(DateTime dt) if (_inhibit) return Task.FromResult?>(null); - return !_caselogs.TryGetValue(userId, out var caselog) + return !caselogs.TryGetValue(userId, out var caselog) ? Task.FromResult?>([]) : Task.FromResult?>(caselog); } diff --git a/InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs b/InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs deleted file mode 100644 index 7f87c4b..0000000 --- a/InstarBot.Tests.Orchestrator/TestDatabaseContextBuilder.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Discord; -using InstarBot.Test.Framework.Models; -using PaxAndromeda.Instar; -using PaxAndromeda.Instar.DynamoModels; - -namespace InstarBot.Test.Framework; - -public class TestDatabaseContext -{ -} - -public class TestDatabaseContextBuilder -{ - private readonly TestDiscordContextBuilder? _discordContextBuilder; - - private Dictionary _registeredUsers = new(); - - public TestDatabaseContextBuilder(ref TestDiscordContextBuilder? discordContextBuilder) - { - _discordContextBuilder = discordContextBuilder; - } - - public TestDatabaseContextBuilder RegisterUser(Snowflake userId) - => RegisterUser(userId, x => x); - - public TestDatabaseContextBuilder RegisterUser(Snowflake userId, Func editExpr) - { - if (_registeredUsers.TryGetValue(userId, out InstarUserData? userData)) - { - _registeredUsers[userId] = editExpr(userData); - return this; - } - - if (_discordContextBuilder is null || !_discordContextBuilder.TryGetUser(userId, out TestGuildUser guildUser)) - throw new InvalidOperationException($"You must register {userId.ID} as a Discord user before calling this method."); - - _registeredUsers[userId] = editExpr(InstarUserData.CreateFrom(guildUser)); - - return this; - } -} \ No newline at end of file diff --git a/InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs b/InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs index 934370a..3d3a2f6 100644 --- a/InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs +++ b/InstarBot.Tests.Orchestrator/TestDiscordContextBuilder.cs @@ -1,26 +1,22 @@ -using System.Collections.ObjectModel; -using Discord; -using InstarBot.Test.Framework.Models; +using InstarBot.Test.Framework.Models; +using JetBrains.Annotations; using PaxAndromeda.Instar; using PaxAndromeda.Instar.ConfigModels; using PaxAndromeda.Instar.Services; +using System.Collections.ObjectModel; namespace InstarBot.Test.Framework; -public class TestDiscordContext +public class TestDiscordContext( + Snowflake guildId, + IEnumerable users, + IEnumerable channels, + IEnumerable roles) { - public Snowflake GuildId { get; } - public ReadOnlyCollection Users { get; } - public ReadOnlyCollection Channels { get; } - public ReadOnlyCollection Roles { get; } - - public TestDiscordContext(Snowflake guildId, IEnumerable users, IEnumerable channels, IEnumerable roles) - { - GuildId = guildId; - Users = users.ToList().AsReadOnly(); - Channels = channels.ToList().AsReadOnly(); - Roles = roles.ToList().AsReadOnly(); - } + public Snowflake GuildId { get; } = guildId; + public ReadOnlyCollection Users { get; } = users.ToList().AsReadOnly(); + public ReadOnlyCollection Channels { get; } = channels.ToList().AsReadOnly(); + public ReadOnlyCollection Roles { get; } = roles.ToList().AsReadOnly(); public static TestDiscordContextBuilder Builder => new(); } @@ -37,6 +33,7 @@ public TestDiscordContext Build() return new TestDiscordContext(_guildId, _registeredUsers.Values, _registeredChannels.Values, _registeredRoles.Values); } + [UsedImplicitly] public async Task LoadFromConfig(IDynamicConfigService configService) { var cfg = await configService.GetConfig(); @@ -115,7 +112,6 @@ private void RegisterUser(Snowflake snowflake, string name = "User") _registeredUsers.Add(snowflake, new TestGuildUser(snowflake) { GlobalName = name, - DisplayName = name, Username = name, Nickname = name }); @@ -127,7 +123,7 @@ public TestDiscordContextBuilder RegisterUser(TestGuildUser user) return this; } - internal bool TryGetUser(Snowflake userId, out TestGuildUser testGuildUser) + internal bool TryGetUser(Snowflake userId, out TestGuildUser? testGuildUser) { return _registeredUsers.TryGetValue(userId, out testGuildUser); } diff --git a/InstarBot.Tests.Orchestrator/TestOrchestrator.cs b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs index 448eee3..5db5933 100644 --- a/InstarBot.Tests.Orchestrator/TestOrchestrator.cs +++ b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs @@ -11,10 +11,7 @@ using Serilog; using Serilog.Events; using System.Diagnostics.CodeAnalysis; -using System.Reactive.Subjects; using System.Runtime.InteropServices; -using static Microsoft.ApplicationInsights.MetricDimensionNames.TelemetryContext; -using BindingFlags = System.Reflection.BindingFlags; using InvalidOperationException = Amazon.CloudWatchLogs.Model.InvalidOperationException; namespace InstarBot.Test.Framework @@ -44,7 +41,7 @@ public IGuildUser Actor { } } - public TestGuildUser Subject { get; set; } + public TestGuildUser Subject { get; set; } = null!; public Snowflake GuildID => GetService().GetGuild().Id; @@ -135,8 +132,6 @@ public T GetService() where T : class { public Mock GetCommand(Func constructor) where T : BaseCommand { - var constructors = typeof(T).GetConstructors(); - var mock = new Mock(() => constructor()); var executionChannel = Snowflake.Generate(); diff --git a/InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs b/InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs index 34e6646..ec18337 100644 --- a/InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs +++ b/InstarBot.Tests.Orchestrator/TestServiceProviderBuilder.cs @@ -1,12 +1,10 @@ -using Discord; -using InstarBot.Test.Framework.Models; +using InstarBot.Test.Framework.Models; using InstarBot.Test.Framework.Services; +using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using Moq; using PaxAndromeda.Instar; -using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.Services; -using InvalidOperationException = Amazon.CloudWatchLogs.Model.InvalidOperationException; namespace InstarBot.Test.Framework; @@ -16,15 +14,13 @@ public class TestServiceProviderBuilder private readonly Dictionary _serviceRegistry = new(); private readonly Dictionary _serviceTypeRegistry = new(); - private readonly HashSet _componentRegistry = new(); private string _configPath = DefaultConfigPath; - private TestDiscordContextBuilder? _discordContextBuilder = null; - private TestDatabaseContextBuilder? _databaseContextBuilder = null; - private readonly Dictionary _interactionCallerIds = new(); + private TestDiscordContextBuilder? _discordContextBuilder; private Snowflake _actor = Snowflake.Generate(); private TestGuildUser _subject = new(Snowflake.Generate()); + [UsedImplicitly] public TestServiceProviderBuilder WithConfigPath(string configPath) { _configPath = configPath; @@ -59,13 +55,6 @@ public TestServiceProviderBuilder WithDiscordContext(Func builderExpr) - { - _databaseContextBuilder ??= new TestDatabaseContextBuilder(ref _discordContextBuilder); - _databaseContextBuilder = builderExpr(_databaseContextBuilder); - return this; - } - public TestServiceProviderBuilder WithActor(Snowflake userId) { _actor = userId; @@ -85,8 +74,6 @@ public async Task Build() services.AddSingleton(type, implementation); foreach (var (iType, implType) in _serviceTypeRegistry) services.AddSingleton(iType, implType); - foreach (var type in _componentRegistry) - services.AddTransient(type); IDynamicConfigService configService; if (_serviceRegistry.TryGetValue(typeof(IDynamicConfigService), out var registeredService) && @@ -107,6 +94,7 @@ public async Task Build() RegisterDefaultService(services); RegisterDefaultService(services); RegisterDefaultService(services); + RegisterDefaultService(services); RegisterDefaultService(services); return new TestOrchestrator(services.BuildServiceProvider(), _actor, _subject); diff --git a/InstarBot.Tests.Orchestrator/TestTimeProvider.cs b/InstarBot.Tests.Orchestrator/TestTimeProvider.cs index 45cf7f9..271c56c 100644 --- a/InstarBot.Tests.Orchestrator/TestTimeProvider.cs +++ b/InstarBot.Tests.Orchestrator/TestTimeProvider.cs @@ -23,6 +23,6 @@ public void SetTime(DateTimeOffset time) public override DateTimeOffset GetUtcNow() { - return _time is null ? DateTimeOffset.UtcNow : ((DateTimeOffset) _time).ToUniversalTime(); + return _time?.ToUniversalTime() ?? DateTimeOffset.UtcNow; } } \ No newline at end of file diff --git a/InstarBot.Tests.Unit/DynamoModels/EventListTests.cs b/InstarBot.Tests.Unit/DynamoModels/EventListTests.cs index f041a8c..0ac551d 100644 --- a/InstarBot.Tests.Unit/DynamoModels/EventListTests.cs +++ b/InstarBot.Tests.Unit/DynamoModels/EventListTests.cs @@ -68,7 +68,7 @@ public void Add_LastItem_ShouldBeAddedInMiddle() new(2, DateTime.Now - TimeSpan.FromMinutes(1)) }; - list.Latest().Value.Should().Be(3); + list.Latest()?.Value.Should().Be(3); list.First().Value.Should().Be(2); // Act @@ -117,7 +117,7 @@ public void Add_RandomItems_ShouldBeChronological() private class TestEntry(T value, DateTime date) : ITimedEvent { - public DateTime Date { get; set; } = date; - public T Value { get; set; } = value; + public DateTime Date { get; } = date; + public T Value { get; } = value; } } \ No newline at end of file diff --git a/InstarBot/Caching/MemoryCache.cs b/InstarBot/Caching/MemoryCache.cs index f61b53a..94c6ab5 100644 --- a/InstarBot/Caching/MemoryCache.cs +++ b/InstarBot/Caching/MemoryCache.cs @@ -1,3 +1,4 @@ +using JetBrains.Annotations; using System.Collections.Specialized; using System.Diagnostics.CodeAnalysis; using System.Runtime.Caching; @@ -15,17 +16,20 @@ public MemoryCache(string name, NameValueCollection config, bool ignoreConfigSec { } - public bool Add(string key, T value, DateTimeOffset absoluteExpiration, string regionName = null!) + [UsedImplicitly] + public bool Add(string key, T value, DateTimeOffset absoluteExpiration, string regionName = null!) { return value != null && base.Add(key, value, absoluteExpiration, regionName); } - public bool Add(string key, T value, CacheItemPolicy policy, string regionName = null!) + [UsedImplicitly] + public bool Add(string key, T value, CacheItemPolicy policy, string regionName = null!) { return value != null && base.Add(key, value, policy, regionName); } - public new T Get(string key, string regionName = null!) + [UsedImplicitly] + public new T Get(string key, string regionName = null!) { return (T) base.Get(key, regionName) ?? throw new InvalidOperationException($"{nameof(key)} cannot be null"); } diff --git a/InstarBot/Commands/AutoMemberHoldCommand.cs b/InstarBot/Commands/AutoMemberHoldCommand.cs index 5f1d44a..5dad59c 100644 --- a/InstarBot/Commands/AutoMemberHoldCommand.cs +++ b/InstarBot/Commands/AutoMemberHoldCommand.cs @@ -11,7 +11,7 @@ namespace PaxAndromeda.Instar.Commands; [SuppressMessage("ReSharper", "ClassCanBeSealed.Global")] -public class AutoMemberHoldCommand(IDatabaseService ddbService, IDynamicConfigService dynamicConfigService, IMetricService metricService, TimeProvider timeProvider) : BaseCommand +public class AutoMemberHoldCommand(IDatabaseService ddbService, IDynamicConfigService dynamicConfigService, IMetricService metricService, INotificationService notificationService, TimeProvider timeProvider) : BaseCommand { [UsedImplicitly] [SlashCommand("amh", "Withhold automatic membership grants to a user.")] @@ -62,6 +62,30 @@ string reason }; await dbUser.CommitAsync(); + // Create a notification for the future + await notificationService.QueueNotification(new Notification + { + Actor = modId, + Channel = config.StaffAnnounceChannel, + Type = NotificationType.AutoMemberHold, + Priority = NotificationPriority.Normal, + Subject = "Auto Member Hold Reminder", + Targets = [ + new NotificationTarget { Id = config.StaffRoleID, Type = NotificationTargetType.Role }, + new NotificationTarget { Id = modId, Type = NotificationTargetType.User } + ], + Data = new NotificationData + { + Message = Strings.Command_AutoMemberHold_NotificationMessage, + Fields = [ + new NotificationEmbedField { Name = "**User**", Value = $"<@{user.Id}>\r\n`{user.Id}`", Inline = true }, + new NotificationEmbedField { Name = "**Issuer**", Value = $"<@{modId.ID}>\r\n`{modId.ID}`", Inline = true }, + new NotificationEmbedField { Name = "**Reason**", Value = $"```{reason}```" } + ] + }, + ReferenceUser = user.Id + }, TimeSpan.FromDays(7)); + // TODO: configurable duration? await RespondAsync(string.Format(Strings.Command_AutoMemberHold_Success, user.Id), ephemeral: true); } catch (Exception ex) @@ -108,6 +132,9 @@ IUser user dbUser.Data.AutoMemberHoldRecord = null; await dbUser.CommitAsync(); + // Purge any pending notifications asynchronously. + _ = Task.Factory.StartNew(() => PurgeNotification(user.Id)); + await RespondAsync(string.Format(Strings.Command_AutoMemberUnhold_Success, user.Id), ephemeral: true); } catch (Exception ex) @@ -125,4 +152,21 @@ IUser user } } } + + private async Task PurgeNotification(Snowflake userId) + { + try + { + var results = await ddbService.GetNotificationsByTypeAndReferenceUser(NotificationType.AutoMemberHold, userId); + + foreach (var result in results) + { + Log.Debug("Deleting AMH notification for {UserID} dated {Date}", userId.ID, result.Data.Date); + await result.DeleteAsync(); + } + } catch (Exception ex) + { + Log.Error(ex, "Failed to remove AMH notification for user {UserID}", userId.ID); + } + } } \ No newline at end of file diff --git a/InstarBot/Commands/ReportUserCommand.cs b/InstarBot/Commands/ReportUserCommand.cs index 745f433..e0e7b17 100644 --- a/InstarBot/Commands/ReportUserCommand.cs +++ b/InstarBot/Commands/ReportUserCommand.cs @@ -66,22 +66,28 @@ public async Task ModalResponse(ReportMessageModal modal) await RespondAsync(Strings.Command_ReportUser_ReportSent, ephemeral: true); } - private async Task SendReportMessage(ReportMessageModal modal, IMessage message, IInstarGuild guild) - { + private async Task SendReportMessage(ReportMessageModal modal, IMessage message, IInstarGuild guild) + { Guard.Against.Null(Context.User); - var cfg = await dynamicConfig.GetConfig(); - + var cfg = await dynamicConfig.GetConfig(); + #if DEBUG - const string staffPing = "{{staffping}}"; + const string staffPing = "{{staffping}}"; #else var staffPing = Snowflake.GetMention(() => cfg.StaffRoleID); #endif - await - Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel) - .SendMessageAsync(staffPing, embed: new InstarReportUserEmbed(modal, Context.User, message, guild).Build()); + var announceChannel = Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel); - await metricService.Emit(Metric.ReportUser_ReportsSent, 1); - } + if (announceChannel is null) + { + Log.Error("Could not find staff announce channel by ID {ChannelID}", cfg.StaffAnnounceChannel.ID); + return; + } + + await announceChannel.SendMessageAsync(staffPing, embed: new InstarReportUserEmbed(modal, Context.User, message, guild).Build()); + + await metricService.Emit(Metric.ReportUser_ReportsSent, 1); + } } \ No newline at end of file diff --git a/InstarBot/Commands/SetBirthdayCommand.cs b/InstarBot/Commands/SetBirthdayCommand.cs index 309effe..3d9de5d 100644 --- a/InstarBot/Commands/SetBirthdayCommand.cs +++ b/InstarBot/Commands/SetBirthdayCommand.cs @@ -130,7 +130,6 @@ await RespondAsync( birthday.Birthdate, birthday.Birthdate.UtcDateTime); // Fourth step: Grant birthday role if the user's birthday is today THEIR time. - // TODO: Ensure that a user is granted/removed birthday roles appropriately after setting their birthday IF their birthday is today. if (birthday.IsToday) { // User's birthday is today in their timezone; grant birthday role. @@ -154,6 +153,12 @@ private async Task HandleUnderage(InstarDynamicConfiguration cfg, IGuildUser use { var staffAnnounceChannel = Context.Guild.GetTextChannel(cfg.StaffAnnounceChannel); + if (staffAnnounceChannel is null) + { + Log.Error("Could not find staff announce channel by ID {ChannelID}", cfg.StaffAnnounceChannel.ID); + return; + } + var warningEmbed = new InstarUnderageUserWarningEmbed(cfg, user, user.RoleIds.Contains(cfg.MemberRoleID), birthday).Build(); await staffAnnounceChannel.SendMessageAsync($"<@&{cfg.StaffRoleID}>", embed: warningEmbed); diff --git a/InstarBot/ConfigModels/AutoMemberConfig.cs b/InstarBot/ConfigModels/AutoMemberConfig.cs index 262f88f..fa94058 100644 --- a/InstarBot/ConfigModels/AutoMemberConfig.cs +++ b/InstarBot/ConfigModels/AutoMemberConfig.cs @@ -3,15 +3,18 @@ namespace PaxAndromeda.Instar.ConfigModels; +[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] +[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] +[UsedImplicitly] public sealed class AutoMemberConfig { - [SnowflakeType(SnowflakeType.Role)] public Snowflake HoldRole { get; set; } = null!; - [SnowflakeType(SnowflakeType.Channel)] public Snowflake IntroductionChannel { get; set; } = null!; - public int MinimumJoinAge { get; set; } - public int MinimumMessages { get; set; } - public int MinimumMessageTime { get; set; } - public List RequiredRoles { get; set; } = null!; - public bool EnableGaiusCheck { get; set; } + [SnowflakeType(SnowflakeType.Role)] public Snowflake HoldRole { get; init; } = null!; + [SnowflakeType(SnowflakeType.Channel)] public Snowflake IntroductionChannel { get; init; } = null!; + public int MinimumJoinAge { get; init; } + public int MinimumMessages { get; init; } + public int MinimumMessageTime { get; init; } + public List RequiredRoles { get; init; } = null!; + public bool EnableGaiusCheck { get; init; } } [UsedImplicitly] diff --git a/InstarBot/ConfigModels/InstarDynamicConfiguration.cs b/InstarBot/ConfigModels/InstarDynamicConfiguration.cs index 62a35a0..5f1bf07 100644 --- a/InstarBot/ConfigModels/InstarDynamicConfiguration.cs +++ b/InstarBot/ConfigModels/InstarDynamicConfiguration.cs @@ -18,8 +18,9 @@ public sealed class InstarDynamicConfiguration [SnowflakeType(SnowflakeType.Role)] public Snowflake StaffRoleID { get; set; } = null!; [SnowflakeType(SnowflakeType.Role)] public Snowflake NewMemberRoleID { get; set; } = null!; [SnowflakeType(SnowflakeType.Role)] public Snowflake MemberRoleID { get; set; } = null!; - public Snowflake[] AuthorizedStaffID { get; set; } = null!; - public AutoMemberConfig AutoMemberConfig { get; set; } = null!; + public Snowflake[] AuthorizedStaffID { get; set; } = null!; + public Snowflake[]? AutoKickRoles { get; set; } = null!; + public AutoMemberConfig AutoMemberConfig { get; set; } = null!; public BirthdayConfig BirthdayConfig { get; set; } = null!; public Team[] Teams { get; set; } = null!; public Dictionary FunCommands { get; set; } = null!; @@ -32,14 +33,14 @@ public class BirthdayConfig public Snowflake BirthdayRole { get; set; } = null!; [SnowflakeType(SnowflakeType.Channel)] public Snowflake BirthdayAnnounceChannel { get; set; } = null!; - public int MinimumPermissibleAge { get; set; } + public int MinimumPermissibleAge { get; [UsedImplicitly] set; } public List AgeRoleMap { get; set; } = null!; } [UsedImplicitly] public record AgeRoleMapping { - public int Age { get; set; } + public int Age { get; [UsedImplicitly] set; } [SnowflakeType(SnowflakeType.Role)] public Snowflake Role { get; set; } = null!; diff --git a/InstarBot/DynamoModels/EventList.cs b/InstarBot/DynamoModels/EventList.cs index 57f3a05..d5bded9 100644 --- a/InstarBot/DynamoModels/EventList.cs +++ b/InstarBot/DynamoModels/EventList.cs @@ -1,6 +1,7 @@ using System.Collections; using Amazon.DynamoDBv2.DataModel; using Amazon.DynamoDBv2.DocumentModel; +using JetBrains.Annotations; using Newtonsoft.Json; namespace PaxAndromeda.Instar.DynamoModels; @@ -46,6 +47,7 @@ public void Add(T item) IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } +[UsedImplicitly] public class EventListPropertyConverter : IPropertyConverter where T: ITimedEvent { public DynamoDBEntry ToEntry(object value) diff --git a/InstarBot/DynamoModels/InstarUserData.cs b/InstarBot/DynamoModels/InstarUserData.cs index 8a54478..5146607 100644 --- a/InstarBot/DynamoModels/InstarUserData.cs +++ b/InstarBot/DynamoModels/InstarUserData.cs @@ -104,13 +104,13 @@ public static InstarUserData CreateFrom(IGuildUser user) public record AutoMemberHoldRecord { [DynamoDBProperty("date")] - public DateTime Date { get; set; } + public DateTime Date { get; init; } [DynamoDBProperty("mod", Converter = typeof(InstarSnowflakePropertyConverter))] - public Snowflake ModeratorID { get; set; } + public Snowflake ModeratorID { get; init; } [DynamoDBProperty("reason")] - public string Reason { get; set; } + public string Reason { get; init; } } public interface ITimedEvent @@ -122,10 +122,10 @@ public interface ITimedEvent public record InstarUserDataHistoricalEntry : ITimedEvent { [DynamoDBProperty("date")] - public DateTime Date { get; set; } + public DateTime Date { get; [UsedImplicitly] set; } [DynamoDBProperty("data")] - public T? Data { get; set; } + public T? Data { get; [UsedImplicitly] set; } public InstarUserDataHistoricalEntry() { @@ -175,6 +175,7 @@ public record InstarUserDataReports public Snowflake Reporter { get; set; } } +[UsedImplicitly] public record InstarModLogEntry { [DynamoDBProperty("context")] @@ -249,7 +250,7 @@ public DynamoDBEntry ToEntry(object value) return name?.Value ?? "UNKNOWN"; } - public object FromEntry(DynamoDBEntry entry) + public object? FromEntry(DynamoDBEntry entry) { var sEntry = entry.AsString(); if (sEntry is null || string.IsNullOrWhiteSpace(entry.AsString())) diff --git a/InstarBot/DynamoModels/Notification.cs b/InstarBot/DynamoModels/Notification.cs index ac8b7dc..f2dda05 100644 --- a/InstarBot/DynamoModels/Notification.cs +++ b/InstarBot/DynamoModels/Notification.cs @@ -1,7 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.Serialization; using Amazon.DynamoDBv2.DataModel; -using Amazon.DynamoDBv2.DocumentModel; +using JetBrains.Annotations; + +// Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +#pragma warning disable CS8618 namespace PaxAndromeda.Instar.DynamoModels; @@ -10,43 +13,88 @@ namespace PaxAndromeda.Instar.DynamoModels; public class Notification { [DynamoDBHashKey("guild_id", Converter = typeof(InstarSnowflakePropertyConverter))] - public Snowflake? GuildID { get; set; } + [DynamoDBGlobalSecondaryIndexHashKey("gsi_type_referenceuser", AttributeName = "guild_id")] + public Snowflake GuildID { get; set; } [DynamoDBRangeKey("date")] public DateTime Date { get; set; } + [DynamoDBProperty("type", Converter = typeof(InstarEnumPropertyConverter))] + [DynamoDBGlobalSecondaryIndexHashKey("gsi_type_referenceuser", AttributeName = "type")] + public NotificationType Type { get; set; } + [DynamoDBProperty("actor", Converter = typeof(InstarSnowflakePropertyConverter))] - public Snowflake? Actor { get; set; } + public Snowflake Actor { get; set; } [DynamoDBProperty("subject")] public string Subject { get; set; } [DynamoDBProperty("channel", Converter = typeof(InstarSnowflakePropertyConverter))] - public Snowflake? Channel { get; set; } + public Snowflake Channel { get; set; } [DynamoDBProperty("target")] public List Targets { get; set; } [DynamoDBProperty("priority", Converter = typeof(InstarEnumPropertyConverter))] - public NotificationPriority? Priority { get; set; } = NotificationPriority.Normal; + public NotificationPriority Priority { get; set; } = NotificationPriority.Normal; [DynamoDBProperty("data")] public NotificationData Data { get; set; } + + [DynamoDBProperty("send_attempts")] + public int SendAttempts { get; set; } + + [DynamoDBProperty("reference_user", Converter = typeof(InstarSnowflakePropertyConverter))] + [DynamoDBGlobalSecondaryIndexRangeKey("gsi_type_referenceuser", AttributeName = "reference_user")] + public Snowflake? ReferenceUser { get; set; } +} + +public enum NotificationType +{ + [EnumMember(Value = "NORMAL")] + Normal, + + [EnumMember(Value = "AMH")] + AutoMemberHold } public record NotificationTarget { [DynamoDBProperty("type", Converter = typeof(InstarEnumPropertyConverter))] - public NotificationTargetType Type { get; set; } + public NotificationTargetType Type { get; init; } [DynamoDBProperty("id", Converter = typeof(InstarSnowflakePropertyConverter))] - public Snowflake? Id { get; set; } + public Snowflake Id { get; init; } } public record NotificationData { [DynamoDBProperty("message")] - public string? Message { get; set; } + public string Message { get; init; } + + [DynamoDBProperty("url")] + public string? Url { get; [UsedImplicitly] init; } + + [DynamoDBProperty("image_url")] + public string? ImageUrl { get; [UsedImplicitly] init; } + + [DynamoDBProperty("thumbnail_url")] + public string? ThumbnailUrl { get; [UsedImplicitly] init; } + + [DynamoDBProperty("fields")] + public List? Fields { get; init; } +} + +public record NotificationEmbedField +{ + [DynamoDBProperty("name")] + public string Name { get; init; } + + [DynamoDBProperty("value")] + public string Value { get; init; } + + [DynamoDBProperty("inline")] + public bool? Inline { get; init; } } public enum NotificationTargetType diff --git a/InstarBot/Embeds/InstarEmbed.cs b/InstarBot/Embeds/InstarEmbed.cs index a795ffa..bb40248 100644 --- a/InstarBot/Embeds/InstarEmbed.cs +++ b/InstarBot/Embeds/InstarEmbed.cs @@ -1,4 +1,5 @@ using Discord; +using JetBrains.Annotations; namespace PaxAndromeda.Instar.Embeds; @@ -6,5 +7,6 @@ public abstract class InstarEmbed { public const string InstarLogoUrl = "https://spacegirl.s3.us-east-1.amazonaws.com/instar.png"; + [UsedImplicitly] public abstract Embed Build(); } \ No newline at end of file diff --git a/InstarBot/Embeds/NotificationEmbed.cs b/InstarBot/Embeds/NotificationEmbed.cs new file mode 100644 index 0000000..0cee9f7 --- /dev/null +++ b/InstarBot/Embeds/NotificationEmbed.cs @@ -0,0 +1,54 @@ +using Discord; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; + +namespace PaxAndromeda.Instar.Embeds; + +public class NotificationEmbed(Notification notification, IGuildUser? actor, InstarDynamicConfiguration cfg) + : InstarEmbed +{ + public override Embed Build() + { + var fields = new List(); + + if (notification.Data.Fields is not null) + { + fields.AddRange(notification.Data.Fields.Select(data => + { + var builder = new EmbedFieldBuilder() + .WithName(data.Name) + .WithValue(data.Value); + + if (data.Inline is true) + builder = builder.WithIsInline(true); + + return builder; + })); + } + + var builder = new EmbedBuilder() + // Set up all the basic stuff first + .WithTimestamp(notification.Date) + .WithColor(0x0c94e0) + .WithFooter(new EmbedFooterBuilder() + .WithText(Strings.Embed_Notification_Footer) + .WithIconUrl(Strings.InstarLogoUrl)) + // Description + .WithTitle(notification.Subject) + .WithDescription(notification.Data.Message) + .WithFields(fields); + + builder = actor is not null + ? builder.WithAuthor(actor.DisplayName, actor.GetDisplayAvatarUrl()) + : builder.WithAuthor(cfg.BotName, Strings.InstarLogoUrl); + + if (notification.Data.Url is not null) + builder = builder.WithUrl(notification.Data.Url); + if (notification.Data.ImageUrl is not null) + builder = builder.WithImageUrl(notification.Data.ImageUrl); + if (notification.Data.ThumbnailUrl is not null) + builder = builder.WithThumbnailUrl(notification.Data.ThumbnailUrl); + + return builder.Build(); + } +} \ No newline at end of file diff --git a/InstarBot/IBuilder.cs b/InstarBot/IBuilder.cs index ef77248..40d5f7d 100644 --- a/InstarBot/IBuilder.cs +++ b/InstarBot/IBuilder.cs @@ -1,6 +1,9 @@ -namespace PaxAndromeda.Instar; +using JetBrains.Annotations; + +namespace PaxAndromeda.Instar; public interface IBuilder { + [UsedImplicitly] T Build(); } \ No newline at end of file diff --git a/InstarBot/IInstarGuild.cs b/InstarBot/IInstarGuild.cs index 59d1c7a..dd3b3e5 100644 --- a/InstarBot/IInstarGuild.cs +++ b/InstarBot/IInstarGuild.cs @@ -9,6 +9,6 @@ public interface IInstarGuild ulong Id { get; } IEnumerable TextChannels { get; } - ITextChannel GetTextChannel(ulong channelId); - IRole GetRole(Snowflake newMemberRole); + ITextChannel? GetTextChannel(ulong channelId); + IRole? GetRole(Snowflake newMemberRole); } \ No newline at end of file diff --git a/InstarBot/InstarBot.csproj b/InstarBot/InstarBot.csproj index 16d4c96..19d97bf 100644 --- a/InstarBot/InstarBot.csproj +++ b/InstarBot/InstarBot.csproj @@ -27,6 +27,7 @@ + diff --git a/InstarBot/Metrics/Metric.cs b/InstarBot/Metrics/Metric.cs index 2515155..363dfbc 100644 --- a/InstarBot/Metrics/Metric.cs +++ b/InstarBot/Metrics/Metric.cs @@ -5,7 +5,6 @@ namespace PaxAndromeda.Instar.Metrics; [SuppressMessage("ReSharper", "InconsistentNaming")] public enum Metric { - [MetricName("Schedule Deviation")] ScheduledService_ScheduleDeviation, @@ -51,6 +50,10 @@ public enum Metric [MetricDimension("Service", "Auto Member System")] [MetricName("AMH Application Failures")] AMS_AMHFailures, + + [MetricDimension("Service", "Auto Member System")] + [MetricName("Autokicks due to Forbidden Roles")] + AMS_ForbiddenRoleKicks, [MetricDimension("Service", "Birthday System")] [MetricName("Birthday System Failures")] @@ -82,5 +85,25 @@ public enum Metric [MetricDimension("Service", "Gaius")] [MetricName("Gaius API Latency")] - Gaius_APILatency + Gaius_APILatency, + + [MetricDimension("Service", "Notifications")] + [MetricName("Malformed Notifications")] + Notification_MalformedNotification, + + [MetricDimension("Service", "Notifications")] + [MetricName("Notifications Failed")] + Notification_NotificationsFailed, + + [MetricDimension("Service", "Notifications")] + [MetricName("Notifications Sent")] + Notification_NotificationsSent, + + [MetricDimension("Service", "Time")] + [MetricName("NTP Query Errors")] + NTP_Error, + + [MetricDimension("Service", "Time")] + [MetricName("Clock Drift (µs)")] + NTP_Drift, } \ No newline at end of file diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index 5dfd878..db0615d 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Amazon; using Amazon.CloudWatchLogs; +using CommandLine; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using PaxAndromeda.Instar.Commands; @@ -74,7 +75,9 @@ private static async Task RunAsync(IConfiguration config) // Start up other systems List tasks = [ _services.GetRequiredService().Start(), - _services.GetRequiredService().Start() + _services.GetRequiredService().Start(), + _services.GetRequiredService().Start(), + _services.GetRequiredService().Start() ]; Task.WaitAll(tasks); @@ -135,7 +138,9 @@ private static ServiceProvider ConfigureServices(IConfiguration config) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(TimeProvider.System); + services.AddSingleton(); #if DEBUG services.AddSingleton(); diff --git a/InstarBot/Services/AWSDynamicConfigService.cs b/InstarBot/Services/AWSDynamicConfigService.cs index 1b8d7f4..842d81d 100644 --- a/InstarBot/Services/AWSDynamicConfigService.cs +++ b/InstarBot/Services/AWSDynamicConfigService.cs @@ -67,7 +67,7 @@ public async Task GetConfig() { await _pollSemaphore.WaitAsync(); if (_timeProvider.GetUtcNow().UtcDateTime > _nextPollTime) - await Poll(false); + await Poll(); return _current; } @@ -100,10 +100,12 @@ public async Task Initialize() }); _nextToken = configSession.InitialConfigurationToken; - await Poll(true); + await Poll(); } - private async Task Poll(bool bypass) + private const string ServiceName = "Dynamic Config"; + + private async Task Poll() { try { @@ -114,23 +116,23 @@ private async Task Poll(bool bypass) _nextToken = result.NextPollConfigurationToken; _nextPollTime = _timeProvider.GetUtcNow().UtcDateTime + TimeSpan.FromSeconds((double)(result.NextPollIntervalInSeconds ?? 60)); - - // Per the documentation, if VersionLabel is empty, then the client - // has the most up-to-date configuration already stored. We can stop - // here. - if (!bypass && string.IsNullOrEmpty(result.VersionLabel)) - return; - if (!string.IsNullOrEmpty(result.VersionLabel)) - Log.Information("Downloading latest configuration version {ConfigVersion} from AppConfig...", result.VersionLabel); - else - Log.Information("Downloading latest configuration from AppConfig..."); + Log.Debug("[{ServiceName}] Next polling time: {NextPollTime}", ServiceName, _nextPollTime); + + // Per the documentation, if configuration is empty, then the client + // has the most up-to-date configuration already stored. We can stop + // here. + if (result.Configuration is null) + { + Log.Verbose("[{ServiceName}] No new configuration is available.", ServiceName); + return; + } _configData = Encoding.UTF8.GetString(result.Configuration.ToArray()); _current = JsonConvert.DeserializeObject(_configData) ?? throw new ConfigurationException("Failed to parse configuration"); - Log.Information("Done downloading latest configuration!"); + Log.Information("[{ServiceName}] New configuration downloaded from AppConfig!", ServiceName); } catch (Exception ex) { diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index def17c3..4769707 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -178,6 +178,7 @@ private async Task HandleUserJoined(IGuildUser user) await _metricService.Emit(Metric.Discord_UsersJoined, 1); } + private async Task HandleUserLeft(IUser arg) { // TODO: Maybe handle something here later @@ -226,6 +227,25 @@ private async Task HandleUserUpdated(UserUpdatedEventArgs arg) Log.Information("Updated metadata for user {Username} (user ID {UserID})", arg.After.Username, arg.ID); await user.CommitAsync(); } + + // Does the user have any of the auto kick roles? + try + { + var cfg = await _dynamicConfig.GetConfig(); + + if (cfg.AutoKickRoles is null) + return; + + if (!cfg.AutoKickRoles.ContainsAny(arg.After.RoleIds.Select(n => new Snowflake(n)).ToArray())) + return; + + await arg.After.KickAsync("Automatically kicked for having a forbidden role."); + await _metricService.Emit(Metric.AMS_ForbiddenRoleKicks, 1); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to determine if user {UserID} has any forbidden roles.", arg.ID.ID); + } } public override async Task RunAsync() diff --git a/InstarBot/Services/BirthdaySystem.cs b/InstarBot/Services/BirthdaySystem.cs index 6f4aba4..49e99d5 100644 --- a/InstarBot/Services/BirthdaySystem.cs +++ b/InstarBot/Services/BirthdaySystem.cs @@ -16,6 +16,9 @@ public sealed class BirthdaySystem ( TimeProvider timeProvider) : ScheduledService("*/5 * * * *", timeProvider, metricService, "Birthday System"), IBirthdaySystem { + private readonly TimeProvider _timeProvider = timeProvider; + private readonly IMetricService _metricService = metricService; + /// /// The maximum age to be considered 'valid' for age role assignment. /// @@ -31,12 +34,12 @@ internal override Task Initialize() public override async Task RunAsync() { var cfg = await dynamicConfig.GetConfig(); - var currentTime = timeProvider.GetUtcNow().UtcDateTime; + var currentTime = _timeProvider.GetUtcNow().UtcDateTime; await RemoveBirthdays(cfg, currentTime); var successfulAdds = await GrantBirthdays(cfg, currentTime); - await metricService.Emit(Metric.BirthdaySystem_Grants, successfulAdds.Count); + await _metricService.Emit(Metric.BirthdaySystem_Grants, successfulAdds.Count); // Now we can create a happy announcement message if (successfulAdds.Count == 0) @@ -129,7 +132,7 @@ private async Task> GrantBirthdays(InstarDynamicConfiguration cf catch (Exception ex) { Log.Error(ex, "Failed to run birthday routine"); - await metricService.Emit(Metric.BirthdaySystem_Failures, 1); + await _metricService.Emit(Metric.BirthdaySystem_Failures, 1); return [ ]; } diff --git a/InstarBot/Services/CloudwatchMetricService.cs b/InstarBot/Services/CloudwatchMetricService.cs index 62c8e60..051b399 100644 --- a/InstarBot/Services/CloudwatchMetricService.cs +++ b/InstarBot/Services/CloudwatchMetricService.cs @@ -1,17 +1,17 @@ -using System.Collections.Specialized; -using System.Net; using Amazon; using Amazon.CloudWatch; using Amazon.CloudWatch.Model; using Amazon.Runtime; +using JetBrains.Annotations; using Microsoft.Extensions.Configuration; -using PaxAndromeda.Instar; using PaxAndromeda.Instar.Metrics; using Serilog; +using System.Net; using Metric = PaxAndromeda.Instar.Metrics.Metric; namespace PaxAndromeda.Instar.Services; +[UsedImplicitly] public sealed class CloudwatchMetricService : IMetricService { // Exponential backoff parameters diff --git a/InstarBot/Services/DiscordService.cs b/InstarBot/Services/DiscordService.cs index 0ec0a34..3854682 100644 --- a/InstarBot/Services/DiscordService.cs +++ b/InstarBot/Services/DiscordService.cs @@ -318,6 +318,6 @@ public async IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime } } - public async Task GetChannel(Snowflake channelId) + public async Task GetChannel(Snowflake channelId) => await _socketClient.GetChannelAsync(channelId); } \ No newline at end of file diff --git a/InstarBot/Services/FileSystemMetricService.cs b/InstarBot/Services/FileSystemMetricService.cs index 6359d97..50a2123 100644 --- a/InstarBot/Services/FileSystemMetricService.cs +++ b/InstarBot/Services/FileSystemMetricService.cs @@ -1,9 +1,11 @@ -using System.Reflection; +using JetBrains.Annotations; using PaxAndromeda.Instar.Metrics; using Serilog; +using System.Reflection; namespace PaxAndromeda.Instar.Services; +[UsedImplicitly] public class FileSystemMetricService : IMetricService { public FileSystemMetricService() diff --git a/InstarBot/Services/IDatabaseService.cs b/InstarBot/Services/IDatabaseService.cs index 8653cf8..2229296 100644 --- a/InstarBot/Services/IDatabaseService.cs +++ b/InstarBot/Services/IDatabaseService.cs @@ -59,14 +59,7 @@ public interface IDatabaseService // TODO: documentation Task>> GetPendingNotifications(); - /// - /// Creates a new database representation from a provided . - /// - /// An instance of . - /// An instance of . - /// - /// When a new user is created with this method, it is *not* created in DynamoDB until - /// is called. - /// Task> CreateNotificationAsync(Notification notification); + + Task>> GetNotificationsByTypeAndReferenceUser(NotificationType type, Snowflake userId); } \ No newline at end of file diff --git a/InstarBot/Services/IDiscordService.cs b/InstarBot/Services/IDiscordService.cs index fe6c5f9..2302cf7 100644 --- a/InstarBot/Services/IDiscordService.cs +++ b/InstarBot/Services/IDiscordService.cs @@ -23,7 +23,7 @@ public interface IDiscordService Task Start(IServiceProvider provider); IInstarGuild GetGuild(); Task> GetAllUsers(); - Task GetChannel(Snowflake channelId); + Task GetChannel(Snowflake channelId); IAsyncEnumerable GetMessages(IInstarGuild guild, DateTime afterTime); IGuildUser? GetUser(Snowflake snowflake); IEnumerable GetAllUsersWithRole(Snowflake roleId); diff --git a/InstarBot/Services/IRunnableService.cs b/InstarBot/Services/IRunnableService.cs index af7674b..48d721d 100644 --- a/InstarBot/Services/IRunnableService.cs +++ b/InstarBot/Services/IRunnableService.cs @@ -5,5 +5,5 @@ public interface IRunnableService /// /// Runs the service. /// - Task RunAsync(); + public Task RunAsync(); } \ No newline at end of file diff --git a/InstarBot/Services/IScheduledService.cs b/InstarBot/Services/IScheduledService.cs index 3fe7f46..62f4d55 100644 --- a/InstarBot/Services/IScheduledService.cs +++ b/InstarBot/Services/IScheduledService.cs @@ -1,5 +1,3 @@ namespace PaxAndromeda.Instar.Services; -public interface IScheduledService : IStartableService, IRunnableService -{ -} \ No newline at end of file +public interface IScheduledService : IStartableService, IRunnableService; \ No newline at end of file diff --git a/InstarBot/Services/IStartableService.cs b/InstarBot/Services/IStartableService.cs index 4213947..06998ae 100644 --- a/InstarBot/Services/IStartableService.cs +++ b/InstarBot/Services/IStartableService.cs @@ -5,5 +5,5 @@ public interface IStartableService /// /// Starts the scheduled service. /// - Task Start(); + public Task Start(); } \ No newline at end of file diff --git a/InstarBot/Services/InstarDynamoDBService.cs b/InstarBot/Services/InstarDynamoDBService.cs index a847162..eb27433 100644 --- a/InstarBot/Services/InstarDynamoDBService.cs +++ b/InstarBot/Services/InstarDynamoDBService.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; using Amazon; using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.DataModel; @@ -54,9 +55,9 @@ public async Task> GetOrCreateUserAsync(IGui return new InstarDatabaseEntry(_ddbContext, data); } - public async Task> CreateNotificationAsync(Notification notification) + public Task> CreateNotificationAsync(Notification notification) { - return new InstarDatabaseEntry(_ddbContext, notification); + return Task.FromResult(new InstarDatabaseEntry(_ddbContext, notification)); } public async Task>> GetBatchUsersAsync(IEnumerable snowflakes) @@ -72,7 +73,7 @@ public async Task>> GetBatchUsersAsync( public async Task>> GetPendingNotifications() { - var currentTime = _timeProvider.GetUtcNow(); + var currentTime = _timeProvider.GetUtcNow().UtcDateTime; var config = new QueryOperationConfig { @@ -86,7 +87,7 @@ public async Task>> GetPendingNotificatio ExpressionAttributeValues = new Dictionary { [":g"] = _guildId, - [":now"] = currentTime.ToString("O") + [":now"] = Utilities.ToDynamoDBCompatibleDateTime(currentTime) } } }; @@ -98,6 +99,39 @@ public async Task>> GetPendingNotificatio return results.Select(u => new InstarDatabaseEntry(_ddbContext, u)).ToList(); } + public async Task>> GetNotificationsByTypeAndReferenceUser(NotificationType type, Snowflake userId) + { + var attr = type.GetAttributeOfType(); + if (attr is null) + throw new ArgumentException("Notification type enum value must have an EnumMember attribute.", nameof(type)); + + var attrVal = attr.Value ?? throw new ArgumentException("Notification type enum value must have an EnumMember attribute with Value defined.", nameof(type)); + + var opConfig = new QueryOperationConfig + { + IndexName = "gsi_type_referenceuser", + KeyExpression = new Expression + { + ExpressionStatement = "guild_id = :g AND #TYPE = :ty AND reference_user = :uid", + ExpressionAttributeNames = new Dictionary + { + ["#TYPE"] = "type" + }, + ExpressionAttributeValues = new Dictionary + { + [":g"] = _guildId, + [":ty"] = attrVal, + [":uid"] = userId.ID.ToString() + } + } + }; + + var qry = _ddbContext.FromQueryAsync(opConfig); + var results = await qry.GetRemainingAsync(); + + return results.Select(n => new InstarDatabaseEntry(_ddbContext, n)).ToList(); + } + public async Task>> GetUsersByBirthday(DateTimeOffset birthdate, TimeSpan fuzziness) { var birthday = new Birthday(birthdate, _timeProvider); @@ -125,25 +159,21 @@ public async Task>> GetUsersByBirthday( var results = new List>(); - foreach (var range in ranges) + foreach (var search in ranges.Select(range => new QueryOperationConfig + { + IndexName = "birthdate-gsi", + KeyExpression = new Expression + { + ExpressionStatement = "guild_id = :g AND birthdate BETWEEN :from AND :to", + ExpressionAttributeValues = new Dictionary + { + [":g"] = _guildId, + [":from"] = range.From, + [":to"] = range.To + } + } + }).Select(config => _ddbContext.FromQueryAsync(config))) { - var config = new QueryOperationConfig - { - IndexName = "birthdate-gsi", - KeyExpression = new Expression - { - ExpressionStatement = "guild_id = :g AND birthdate BETWEEN :from AND :to", - ExpressionAttributeValues = new Dictionary - { - [":g"] = _guildId, - [":from"] = range.From, - [":to"] = range.To - } - } - }; - - var search = _ddbContext.FromQueryAsync(config); - var page = await search.GetRemainingAsync().ConfigureAwait(false); results.AddRange(page.Select(u => new InstarDatabaseEntry(_ddbContext, u))); } diff --git a/InstarBot/Services/NTPService.cs b/InstarBot/Services/NTPService.cs new file mode 100644 index 0000000..b9dee1a --- /dev/null +++ b/InstarBot/Services/NTPService.cs @@ -0,0 +1,71 @@ +using GuerrillaNtp; +using PaxAndromeda.Instar.Metrics; +using Serilog; + +namespace PaxAndromeda.Instar.Services; + +/// +/// This service monitors and emits the clock drift metric which helps +/// operators be aware of a clock issue. +/// +/// +/// Why? Many of our interactions have a time limit of 3 seconds for a +/// response. This is enforced locally with Discord.NET. It has been +/// observed that if there is a significant clock drift from UTC time, +/// the bot can enter an undefined state where it cannot process +/// interaction commands any further. +/// +/// Whilst this is unlikely to occur on a dedicated blade server from +/// a hosting provider, having a metric for this to root cause issues +/// is crucial for investigatory purposes. +/// +public class NTPService (TimeProvider timeProvider, IMetricService metricService) + : ScheduledService("*/5 * * * *", timeProvider, metricService, "NTP Service") +{ + /// + /// The hostname for NIST's NTP servers. We will trust them as the + /// authority on time. + /// + private const string Hostname = "time.nist.gov"; + + private readonly IMetricService _metricService = metricService; + private NtpClient _ntpClient = null!; + + internal override Task Initialize() + { + _ntpClient = new NtpClient(Hostname); + + return Task.CompletedTask; + } + + public override async Task RunAsync() + { + try + { + var result = await _ntpClient.QueryAsync(); + + if (result is null) + { + Log.Error("Failed to query NTP time from {Hostname}.", Hostname); + await _metricService.Emit(Metric.NTP_Error, 1); + return; + } + + await _metricService.Emit(Metric.NTP_Drift, result.CorrectionOffset.TotalMicroseconds); + + Log.Debug("[{ServiceName}] Time Drift: {TimeDrift:N}µs", "NTP", result.CorrectionOffset.TotalMicroseconds); + + } catch (Exception ex) + { + Log.Error(ex, "Failed to query NTP time from {Hostname}.", Hostname); + + try + { + await _metricService.Emit(Metric.NTP_Error, 1); + } catch (Exception ex2) + { + Log.Error(ex2, "Failed to emit NTP query error metric."); + } + } + } +} \ No newline at end of file diff --git a/InstarBot/Services/NotificationService.cs b/InstarBot/Services/NotificationService.cs index 76a9ddb..e438c6e 100644 --- a/InstarBot/Services/NotificationService.cs +++ b/InstarBot/Services/NotificationService.cs @@ -1,26 +1,28 @@ -using PaxAndromeda.Instar.DynamoModels; +using Discord; +using PaxAndromeda.Instar.ConfigModels; +using PaxAndromeda.Instar.DynamoModels; +using PaxAndromeda.Instar.Embeds; +using PaxAndromeda.Instar.Metrics; using Serilog; +using System.Text; namespace PaxAndromeda.Instar.Services; -public class NotificationService : ScheduledService +public interface INotificationService : IScheduledService { - private readonly IDatabaseService _dbService; - private readonly IDiscordService _discordService; - private readonly IDynamicConfigService _dynamicConfig; - - public NotificationService( - TimeProvider timeProvider, - IMetricService metricService, - IDatabaseService dbService, - IDiscordService discordService, - IDynamicConfigService dynamicConfig) - : base("* * * * *", timeProvider, metricService, "Notifications Service") - { - _dbService = dbService; - _discordService = discordService; - _dynamicConfig = dynamicConfig; - } + Task QueueNotification(Notification notification, TimeSpan delay); +} + +public class NotificationService ( + TimeProvider timeProvider, + IMetricService metricService, + IDatabaseService dbService, + IDiscordService discordService, + IDynamicConfigService dynamicConfig) + : ScheduledService("* * * * *", timeProvider, metricService, "Notifications Service"), INotificationService +{ + private readonly TimeProvider _timeProvider = timeProvider; + private readonly IMetricService _metricService = metricService; internal override Task Initialize() { @@ -30,21 +32,100 @@ internal override Task Initialize() public override async Task RunAsync() { var notificationQueue = new Queue>( - (await _dbService.GetPendingNotifications()).OrderByDescending(entry => entry.Data.Date) + (await dbService.GetPendingNotifications()).OrderByDescending(entry => entry.Data.Date) ); + var cfg = await dynamicConfig.GetConfig(); + + await discordService.SyncUsers(); + while (notificationQueue.TryDequeue(out var notification)) { - if (!await ProcessNotification(notification)) + if (!await ProcessNotification(notification, cfg)) continue; await notification.DeleteAsync(); } } - private async Task ProcessNotification(InstarDatabaseEntry notification) + private async Task ProcessNotification(InstarDatabaseEntry notification, InstarDynamicConfiguration cfg) { - Log.Information("Processed notification from {Actor} sent on {Date}: {Message}", notification.Data.Actor.ID, notification.Data.Date, notification.Data.Data.Message); - return true; + try + { + Log.Information("Processed notification from {Actor} sent on {Date}: {Message}", notification.Data.Actor.ID, notification.Data.Date, notification.Data.Data.Message); + + IChannel? channel = await discordService.GetChannel(notification.Data.Channel); + if (channel is not ITextChannel textChannel) + { + Log.Error("Failed to send notification dated {NotificationDate}: channel {ChannelId} does not exist", notification.Data.Date, notification.Data.Channel); + await _metricService.Emit(Metric.Notification_MalformedNotification, 1); + return true; + } + + var actor = discordService.GetUser(notification.Data.Actor); + + Log.Debug("Actor ID {UserId} name: {Username}", notification.Data.Actor.ID, actor?.Username ?? ""); + + var embed = new NotificationEmbed(notification.Data, actor, cfg); + + await textChannel.SendMessageAsync(GetNotificationTargetString(notification.Data), embed: embed.Build()); + await _metricService.Emit(Metric.Notification_NotificationsSent, 1); + + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to send notification dated {NotificationDate}: an unknown error has occurred", notification.Data.Date); + await _metricService.Emit(Metric.Notification_NotificationsFailed, 1); + + try + { + // Let's try to mark the notification for a reattempt + notification.Data.SendAttempts++; + await notification.CommitAsync(); + } catch (Exception ex2) + { + Log.Error(ex2, "Failed to update notification dated {NotificationDate}: cannot increment send attempts", notification.Data.Date); + } + } + + return false; + } + + private static string GetNotificationTargetString(Notification notificationData) + { + return notificationData.Targets.Count switch + { + 1 => GetMention(notificationData.Targets.First()), + >= 2 => string.Join(' ', notificationData.Targets.Select(GetMention)).TrimEnd(), + _ => string.Empty + }; + } + + private static string GetMention(NotificationTarget target) + { + StringBuilder builder = new(); + builder.Append(target.Type switch + { + NotificationTargetType.User => "<@", + NotificationTargetType.Role => "<@&", + _ => "<@" + }); + + builder.Append(target.Id.ID); + builder.Append(">"); + + return builder.ToString(); + } + + public async Task QueueNotification(Notification notification, TimeSpan delay) + { + var cfg = await dynamicConfig.GetConfig(); + + notification.Date = _timeProvider.GetUtcNow().UtcDateTime + delay; + notification.GuildID = cfg.TargetGuild; + + var notificationEntry = await dbService.CreateNotificationAsync(notification); + await notificationEntry.CommitAsync(); } } \ No newline at end of file diff --git a/InstarBot/Snowflake.cs b/InstarBot/Snowflake.cs index fe39727..8d27aa0 100644 --- a/InstarBot/Snowflake.cs +++ b/InstarBot/Snowflake.cs @@ -32,6 +32,8 @@ public sealed record Snowflake { private static int _increment; + public static readonly DateTime Epoch = new(2015, 1, 1, 0, 0, 0, DateTimeKind.Utc); + /// /// The Discord epoch, defined as the first second of the year 2015. /// diff --git a/InstarBot/Strings.Designer.cs b/InstarBot/Strings.Designer.cs index d234ab7..ec73c1a 100644 --- a/InstarBot/Strings.Designer.cs +++ b/InstarBot/Strings.Designer.cs @@ -105,6 +105,17 @@ public static string Command_AutoMemberHold_Error_Unexpected { } } + /// + /// Looks up a localized string similar to The user listed below has their membership withheld for one week. Please review this member and remove the hold if no further justification exists. + /// + ///No further reminders regarding this user's hold will be sent.. + /// + public static string Command_AutoMemberHold_NotificationMessage { + get { + return ResourceManager.GetString("Command_AutoMemberHold_NotificationMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Membership for user <@{0}> has been withheld. Staff will be notified in one week to review.. /// @@ -600,6 +611,15 @@ public static string Embed_BirthdaySystem_Footer { } } + /// + /// Looks up a localized string similar to Instar Notification System. + /// + public static string Embed_Notification_Footer { + get { + return ResourceManager.GetString("Embed_Notification_Footer", resourceCulture); + } + } + /// /// Looks up a localized string similar to Instar Paging System. /// diff --git a/InstarBot/Strings.resx b/InstarBot/Strings.resx index 427b42b..302548d 100644 --- a/InstarBot/Strings.resx +++ b/InstarBot/Strings.resx @@ -284,10 +284,6 @@ Instar Message Reporting System - - :birthday: <@&{0}> {1}! - {0} is the ID of the Happy Birthday role, {1} is the list of recipients. - and @@ -351,4 +347,16 @@ As the user is a new member, their membership has automatically been withheld pe Successfully reset the birthday of <@{0}>, but the birthday role could not be automatically removed. The user has been notified of the reset by DM. {0} is the user ID. + + :birthday: <@&{0}> {1}! + {0} is the ID of the Happy Birthday role, {1} is the list of recipients. + + + Instar Notification System + + + The user listed below has their membership withheld for one week. Please review this member and remove the hold if no further justification exists. + +No further reminders regarding this user's hold will be sent. + \ No newline at end of file diff --git a/InstarBot/Utilities.cs b/InstarBot/Utilities.cs index 66662bf..d4cb187 100644 --- a/InstarBot/Utilities.cs +++ b/InstarBot/Utilities.cs @@ -152,4 +152,26 @@ public static string ScreamingToPascalCase(string input) /// A string containing the UTC month, day, hour, and minute of the Birthdate in the format "MMddHHmm". public static string ToBirthdateKey(DateTimeOffset dt) => dt.ToUniversalTime().ToString("MMddHHmm", CultureInfo.InvariantCulture); + + /// + /// A format string for millisecond precision ISO-8601 representations of a DateTime. + /// + /// + /// This constant is aligned with the AWS SDK's equivalent constant located here. + /// + private const string ISO8601DateFormat = @"yyyy-MM-dd\THH:mm:ss.fff\Z"; + + /// + /// Converts a DateTime into a DynamoDB compatible ISO-8601 string. + /// + /// The datetime to convert. + /// A millisecond precision ISO-8601 representation of . + /// + /// This method is aligned with the AWS SDK's equivalent method located here. + /// + public static string ToDynamoDBCompatibleDateTime(DateTime dateTime) + { + var utc = dateTime.ToUniversalTime(); + return utc.ToString(ISO8601DateFormat, CultureInfo.InvariantCulture); + } } \ No newline at end of file From eba03363578846c3c9e61c434b76de73234ee9b6 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sat, 27 Dec 2025 11:40:38 -0800 Subject: [PATCH 23/53] Fixed some minor code quality issues. --- InstarBot/Program.cs | 6 +++++- InstarBot/Services/GaiusAPIService.cs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index db0615d..b77629a 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -1,7 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Amazon; using Amazon.CloudWatchLogs; -using CommandLine; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using PaxAndromeda.Instar.Commands; @@ -11,6 +10,10 @@ using Serilog.Formatting.Json; using Serilog.Sinks.AwsCloudWatch; +#if !DEBUG +using CommandLine; +#endif + namespace PaxAndromeda.Instar; [ExcludeFromCodeCoverage] @@ -19,6 +22,7 @@ internal static class Program private static CancellationTokenSource _cts = null!; private static IServiceProvider _services = null!; + // ReSharper disable once UnusedParameter.Global public static async Task Main(string[] args) { AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException; diff --git a/InstarBot/Services/GaiusAPIService.cs b/InstarBot/Services/GaiusAPIService.cs index da88f51..7a22d0b 100644 --- a/InstarBot/Services/GaiusAPIService.cs +++ b/InstarBot/Services/GaiusAPIService.cs @@ -112,7 +112,7 @@ public async Task> GetCaselogsAfter(DateTime dt) return ParseCaselogs(result); } - private static IEnumerable ParseCaselogs(string response) + internal static IEnumerable ParseCaselogs(string response) { // Remove any instances of "totalCases" while (response.Contains("\"totalcases\":", StringComparison.OrdinalIgnoreCase)) From cbebfbac6d7a9b34c5602f470dbdd2908e1e15df Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sat, 27 Dec 2025 18:29:15 -0800 Subject: [PATCH 24/53] Updated Github action to run on hotfix-integ-hanging and develop branches --- .github/workflows/dotnet-linux.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-linux.yml b/.github/workflows/dotnet-linux.yml index 993550a..77e46e6 100644 --- a/.github/workflows/dotnet-linux.yml +++ b/.github/workflows/dotnet-linux.yml @@ -5,7 +5,7 @@ name: Linux on: push: - branches: [ "master" ] + branches: [ "master", "develop", "hotfix-integ-hanging" ] pull_request: branches: [ "master" ] From 477faaaac22266dd206c981071a227a57a81a76f Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sat, 27 Dec 2025 18:33:44 -0800 Subject: [PATCH 25/53] Added extra diagnostics logging to PreloadIntroductionPosters --- InstarBot/Services/AutoMemberSystem.cs | 31 +++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index 4769707..457a498 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -505,15 +505,36 @@ private async Task PreloadIntroductionPosters(InstarDynamicConfiguration cfg) var messages = (await introChannel.GetMessagesAsync().FlattenAsync()).GetEnumerator(); - // Assumption: Last message is the oldest one - while (messages.MoveNext()) // Move to the first message, if there is any - { - IMessage? message; + const int MAX_ITERS = 1000; + + int iters = 0; + int inner_iters = 0; + // Assumption: Last message is the oldest one + while (messages.MoveNext()) // Move to the first message, if there is any + { + iters++; + + if (iters >= MAX_ITERS) + { + Log.Error("Watchdog: exiting PreloadIntroductionPosters loop due to iters ({iters}) exceeding 1,000", iters); + break; + } + + IMessage? message = null; IMessage? oldestMessage = null; do { - message = messages.Current; + inner_iters++; + Log.Information("iters={iters}, inner_iters={inner_iters}", iters, inner_iters); + + if (inner_iters >= MAX_ITERS) + { + Log.Error("Watchdog: exiting PreloadIntroductionPosters loop due to inner_iters ({inner_iters}) exceeding 1,000", inner_iters); + break; + } + + message = messages.Current; if (message is null) break; From 3a92759d918f95160f7f034eeb15e0c01ecad2d3 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sat, 27 Dec 2025 18:46:22 -0800 Subject: [PATCH 26/53] Updated test GetMessagesAsync method --- InstarBot.Tests.Orchestrator/Models/TestChannel.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/InstarBot.Tests.Orchestrator/Models/TestChannel.cs b/InstarBot.Tests.Orchestrator/Models/TestChannel.cs index d52cee2..d12d9a7 100644 --- a/InstarBot.Tests.Orchestrator/Models/TestChannel.cs +++ b/InstarBot.Tests.Orchestrator/Models/TestChannel.cs @@ -73,7 +73,15 @@ public async IAsyncEnumerable> GetMessagesAsync(ul RequestOptions options = null) { var snowflake = new Snowflake(fromMessageId); - var rz = _messages.Values.Where(n => n.Timestamp.UtcDateTime > snowflake.Time.ToUniversalTime()).ToList().AsReadOnly(); + + Func query = dir switch + { + Direction.Before => n => n.Id != fromMessageId && n.Timestamp.UtcDateTime > snowflake.Time.ToUniversalTime(), + Direction.After => n => n.Id != fromMessageId && n.Timestamp.UtcDateTime < snowflake.Time.ToUniversalTime(), + _ => throw new NotImplementedException() + }; + + var rz = _messages.Values.Where(query).ToList().AsReadOnly(); yield return rz; } From d7f2cc01908350277e98faef4064f6e3ef36059a Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sat, 27 Dec 2025 18:47:52 -0800 Subject: [PATCH 27/53] Revert "Added extra diagnostics logging to PreloadIntroductionPosters" This reverts commit 477faaaac22266dd206c981071a227a57a81a76f. --- InstarBot/Services/AutoMemberSystem.cs | 31 +++++--------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index 457a498..4769707 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -505,36 +505,15 @@ private async Task PreloadIntroductionPosters(InstarDynamicConfiguration cfg) var messages = (await introChannel.GetMessagesAsync().FlattenAsync()).GetEnumerator(); - const int MAX_ITERS = 1000; - - int iters = 0; - int inner_iters = 0; - // Assumption: Last message is the oldest one - while (messages.MoveNext()) // Move to the first message, if there is any - { - iters++; - - if (iters >= MAX_ITERS) - { - Log.Error("Watchdog: exiting PreloadIntroductionPosters loop due to iters ({iters}) exceeding 1,000", iters); - break; - } - - IMessage? message = null; + // Assumption: Last message is the oldest one + while (messages.MoveNext()) // Move to the first message, if there is any + { + IMessage? message; IMessage? oldestMessage = null; do { - inner_iters++; - Log.Information("iters={iters}, inner_iters={inner_iters}", iters, inner_iters); - - if (inner_iters >= MAX_ITERS) - { - Log.Error("Watchdog: exiting PreloadIntroductionPosters loop due to inner_iters ({inner_iters}) exceeding 1,000", inner_iters); - break; - } - - message = messages.Current; + message = messages.Current; if (message is null) break; From 00ee31679d12907d533d91b77b5a3497e9d55a88 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sat, 27 Dec 2025 18:52:15 -0800 Subject: [PATCH 28/53] Removed hotfix branch from dotnet-linux.yml --- .github/workflows/dotnet-linux.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-linux.yml b/.github/workflows/dotnet-linux.yml index 77e46e6..56eaa20 100644 --- a/.github/workflows/dotnet-linux.yml +++ b/.github/workflows/dotnet-linux.yml @@ -5,7 +5,7 @@ name: Linux on: push: - branches: [ "master", "develop", "hotfix-integ-hanging" ] + branches: [ "master", "develop" ] pull_request: branches: [ "master" ] From af4198cd44146973525f731d7d85310049f72f71 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sun, 28 Dec 2025 11:12:06 -0800 Subject: [PATCH 29/53] Update appspec.yml for .NET 10 --- appspec.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appspec.yml b/appspec.yml index fe67e6b..6c3eec7 100644 --- a/appspec.yml +++ b/appspec.yml @@ -3,7 +3,7 @@ os: linux files: - source: Commands destination: /Instar/Commands - - source: InstarBot/bin/Release/net6.0 + - source: InstarBot/bin/Release/net10.0 destination: /Instar/bin hooks: ApplicationStop: From beae044ee30b98f3e721062adcf4d1b8fb1abeb5 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sun, 28 Dec 2025 15:15:21 -0800 Subject: [PATCH 30/53] Minor bugfix with cloudwatch metrics + Discord stop --- .../Services/TestDiscordService.cs | 5 +++++ InstarBot/Program.cs | 8 ++++---- InstarBot/Services/CloudwatchMetricService.cs | 3 +++ InstarBot/Services/IDiscordService.cs | 1 + 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs b/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs index 0ecee3e..8d6cd69 100644 --- a/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs +++ b/InstarBot.Tests.Orchestrator/Services/TestDiscordService.cs @@ -104,6 +104,11 @@ public Task SyncUsers() return Task.CompletedTask; } + public Task Stop() + { + return Task.CompletedTask; + } + public void CreateChannel(Snowflake channelId) { _guild.AddChannel(new TestChannel(channelId)); diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index b77629a..ade8bf3 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -54,10 +54,10 @@ public static async Task Main(string[] args) private static async void StopSystem(object? sender, ConsoleCancelEventArgs e) { try - { - await _services.GetRequiredService().Stop(); - await _cts.CancelAsync(); - } + { + await _services.GetRequiredService().Stop(); + await _cts.CancelAsync(); + } catch (Exception err) { Log.Fatal(err, "FATAL: Unhandled exception caught during shutdown"); diff --git a/InstarBot/Services/CloudwatchMetricService.cs b/InstarBot/Services/CloudwatchMetricService.cs index 051b399..029bb33 100644 --- a/InstarBot/Services/CloudwatchMetricService.cs +++ b/InstarBot/Services/CloudwatchMetricService.cs @@ -70,7 +70,10 @@ public async Task Emit(Metric metric, double value, Dictionary GetAllUsersWithRole(Snowflake roleId); Task SyncUsers(); + Task Stop(); } \ No newline at end of file From ab6d8811e9f069f250d7d9ff9024e9ff5e247dfe Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sun, 28 Dec 2025 17:01:20 -0800 Subject: [PATCH 31/53] Fixed some metrics related bugs --- InstarBot/Metrics/Metric.cs | 2 +- InstarBot/Services/CloudwatchMetricService.cs | 15 ++------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/InstarBot/Metrics/Metric.cs b/InstarBot/Metrics/Metric.cs index 363dfbc..427e407 100644 --- a/InstarBot/Metrics/Metric.cs +++ b/InstarBot/Metrics/Metric.cs @@ -104,6 +104,6 @@ public enum Metric NTP_Error, [MetricDimension("Service", "Time")] - [MetricName("Clock Drift (µs)")] + [MetricName("Clock Drift")] NTP_Drift, } \ No newline at end of file diff --git a/InstarBot/Services/CloudwatchMetricService.cs b/InstarBot/Services/CloudwatchMetricService.cs index 029bb33..cac8642 100644 --- a/InstarBot/Services/CloudwatchMetricService.cs +++ b/InstarBot/Services/CloudwatchMetricService.cs @@ -66,20 +66,9 @@ public async Task Emit(Metric metric, double value, Dictionary(); if (attrs != null) - foreach (var dim in attrs) + foreach (MetricDimensionAttribute? dim in attrs.Where(dim => !dimensions.ContainsKey(dim.Name))) { - // Always prefer the passed-in dimensions over attribute-defined ones when there's a conflict - if (!dimensions.ContainsKey(dim.Name)) - { - dimensions.Add(dim.Name, dim.Value); - continue; - } - - datum.Dimensions.Add(new Dimension - { - Name = dim.Name, - Value = dim.Value - }); + dimensions.Add(dim.Name, dim.Value); } foreach (var (dName, dValue) in dimensions) From dc5a55f9c80e6174ef6fbda21818ca2565a5d71d Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Sun, 28 Dec 2025 17:04:44 -0800 Subject: [PATCH 32/53] Bugfix: Missing user ID mention in eligibility embed --- InstarBot/Embeds/InstarEligibilityEmbed.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/InstarBot/Embeds/InstarEligibilityEmbed.cs b/InstarBot/Embeds/InstarEligibilityEmbed.cs index 8294819..b50a2a5 100644 --- a/InstarBot/Embeds/InstarEligibilityEmbed.cs +++ b/InstarBot/Embeds/InstarEligibilityEmbed.cs @@ -37,12 +37,17 @@ protected override EmbedBuilder BuildParts(EmbedBuilder builder) .WithValue(BuildEligibilityText(eligibility))); } + string eligibilityDescription = string.Format(eligibility.HasFlag(MembershipEligibility.Eligible) + ? Strings.Command_Eligibility_EligibleText + : Strings.Command_Eligibility_IneligibleText, + user.Id); + return builder .WithAuthor(new EmbedAuthorBuilder() .WithName(user.Username) .WithIconUrl(user.GetAvatarUrl()) ) - .WithDescription(eligibility.HasFlag(MembershipEligibility.Eligible) ? Strings.Command_Eligibility_EligibleText : Strings.Command_Eligibility_IneligibleText) + .WithDescription(eligibilityDescription) .WithFields(fields); } From 4a39b3698c83a1f081f4d1c6ead81729f2f05aa0 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 29 Dec 2025 14:32:39 -0800 Subject: [PATCH 33/53] Bugfix: All template variables in Strings.resx must be replaced with other text. Also, added a `ToString()` implementation under Snowflake to avoid ugly deserialization like `Snowflake { Time = ..., InternalWorkerId = ..., InternalProcessId = ..., GeneratedId = ..., ID = ... }` --- InstarBot.Tests.Common/MetaTests.cs | 11 +++++++++++ InstarBot.Tests.Common/TestUtilities.cs | 9 +++++++-- InstarBot/Snowflake.cs | 5 +++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/InstarBot.Tests.Common/MetaTests.cs b/InstarBot.Tests.Common/MetaTests.cs index a032209..45c7509 100644 --- a/InstarBot.Tests.Common/MetaTests.cs +++ b/InstarBot.Tests.Common/MetaTests.cs @@ -15,6 +15,7 @@ public static void MatchesFormat_WithValidText_ShouldReturnTrue() result.Should().BeTrue(); } + [Fact] public static void MatchesFormat_WithRegexReservedCharacters_ShouldReturnTrue() { @@ -26,6 +27,16 @@ public static void MatchesFormat_WithRegexReservedCharacters_ShouldReturnTrue() result.Should().BeTrue(); } + [Fact] + public static void MatchesFormat_WithTemplateNotReplaced_ShouldReturnFalse() + { + const string text = "Test {0} Test"; + + bool result = TestUtilities.MatchesFormat(text, text); + + result.Should().BeFalse(); + } + [Theory] [InlineData("You are missing age role.")] [InlineData("You are missing an role.")] diff --git a/InstarBot.Tests.Common/TestUtilities.cs b/InstarBot.Tests.Common/TestUtilities.cs index 678ec89..3294756 100644 --- a/InstarBot.Tests.Common/TestUtilities.cs +++ b/InstarBot.Tests.Common/TestUtilities.cs @@ -33,8 +33,13 @@ public static bool MatchesFormat(string text, string format, bool partial = fals // We cannot simply replace the escaped template variables, as that would escape the braces. formatRegex = formatRegex.Replace("\\{", "{").Replace("\\}", "}"); - // Replaces any template variable (e.g., {0}, {name}, etc.) with a regex wildcard that matches any text. - formatRegex = Regex.Replace(formatRegex, "{.+?}", "(?:.+?)"); + // Replaces any template variable (e.g., {0}, {name}, etc.) with a regex wildcard that matches any text + // that is not the original template itself. + formatRegex = Regex.Replace( + formatRegex, + "{(.+?)}", + m => "(?:(?!\\{" + Regex.Escape(m.Groups[1].Value) +"\\}).+?)" + ); return Regex.IsMatch(text, formatRegex); } diff --git a/InstarBot/Snowflake.cs b/InstarBot/Snowflake.cs index 8d27aa0..9168344 100644 --- a/InstarBot/Snowflake.cs +++ b/InstarBot/Snowflake.cs @@ -218,6 +218,11 @@ public bool Equals(Snowflake? other) return ID == other.ID; } + public override string ToString() + { + return ID.ToString(); + } + public override int GetHashCode() { return ID.GetHashCode(); From 80ebb4dc5cab3259eb23c5e45649270f8d84852c Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 29 Dec 2025 15:26:51 -0800 Subject: [PATCH 34/53] Bugfix: Successful setbirthday response will return requested date/time. --- InstarBot.Tests.Unit/UtilitiesTests.cs | 57 ++++++++++++++++++------ InstarBot/Commands/SetBirthdayCommand.cs | 5 ++- InstarBot/Strings.Designer.cs | 2 +- InstarBot/Strings.resx | 4 +- InstarBot/Utilities.cs | 47 ++++++++++++++++++- 5 files changed, 96 insertions(+), 19 deletions(-) diff --git a/InstarBot.Tests.Unit/UtilitiesTests.cs b/InstarBot.Tests.Unit/UtilitiesTests.cs index d15c70c..fe041b2 100644 --- a/InstarBot.Tests.Unit/UtilitiesTests.cs +++ b/InstarBot.Tests.Unit/UtilitiesTests.cs @@ -1,4 +1,5 @@ -using FluentAssertions; +using System; +using FluentAssertions; using PaxAndromeda.Instar; using Xunit; @@ -6,17 +7,45 @@ namespace InstarBot.Tests; public class UtilitiesTests { - [Theory] - [InlineData("OWNER", "Owner")] - [InlineData("ADMIN", "Admin")] - [InlineData("MODERATOR", "Moderator")] - [InlineData("SENIOR_HELPER", "SeniorHelper")] - [InlineData("HELPER", "Helper")] - [InlineData("COMMUNITY_MANAGER", "CommunityManager")] - [InlineData("MEMBER", "Member")] - [InlineData("NEW_MEMBER", "NewMember")] - public void ScreamingToSnakeCase_ShouldProduceValidSnakeCase(string input, string expected) - { - Utilities.ScreamingToPascalCase(input).Should().Be(expected); - } + [Theory] + [InlineData("OWNER", "Owner")] + [InlineData("ADMIN", "Admin")] + [InlineData("MODERATOR", "Moderator")] + [InlineData("SENIOR_HELPER", "SeniorHelper")] + [InlineData("HELPER", "Helper")] + [InlineData("COMMUNITY_MANAGER", "CommunityManager")] + [InlineData("MEMBER", "Member")] + [InlineData("NEW_MEMBER", "NewMember")] + public void ScreamingToSnakeCase_ShouldProduceValidSnakeCase(string input, string expected) + { + Utilities.ScreamingToPascalCase(input).Should().Be(expected); + } + + [Theory] + [InlineData(1, "st")] + [InlineData(2, "nd")] + [InlineData(3, "rd")] + [InlineData(4, "th")] + [InlineData(1011, "th")] + public void GetOrdinal_ShouldProduceValidOutput(int input, string expected) + { + Utilities.GetOrdinal(input).Should().Be(expected); + } + + [Theory] + [InlineData(1, "st")] + [InlineData(2, "nd")] + [InlineData(3, "rd")] + [InlineData(4, "th")] + public void DateTimeOffsetExtension_ToString_WithExtended_ShouldProduceValidOutput(int day, string expectedSuffix) + { + // Arrange + var dateTime = new DateTimeOffset(new DateTime(2020, 3, day)); + + // Act + var result = dateTime.ToString("MMMM dnn", true); + + // Assert + result.Should().Be($"March {day}{expectedSuffix}"); + } } \ No newline at end of file diff --git a/InstarBot/Commands/SetBirthdayCommand.cs b/InstarBot/Commands/SetBirthdayCommand.cs index 3d9de5d..aebd0ce 100644 --- a/InstarBot/Commands/SetBirthdayCommand.cs +++ b/InstarBot/Commands/SetBirthdayCommand.cs @@ -136,7 +136,10 @@ await RespondAsync( await birthdaySystem.GrantUnexpectedBirthday(Context.User, birthday); } - await RespondAsync(string.Format(Strings.Command_SetBirthday_Success, birthday.Timestamp), ephemeral: true); + var extrapolatedBirthday = birthday.Observed; + var dateStr = unspecifiedDate.ToString("MMMM dnn", true); + + await RespondAsync(string.Format(Strings.Command_SetBirthday_Success, dateStr, extrapolatedBirthday.ToUnixTimeSeconds()), ephemeral: true); await metricService.Emit(Metric.BS_BirthdaysSet, 1); } catch (Exception ex) diff --git a/InstarBot/Strings.Designer.cs b/InstarBot/Strings.Designer.cs index ec73c1a..6691ff5 100644 --- a/InstarBot/Strings.Designer.cs +++ b/InstarBot/Strings.Designer.cs @@ -576,7 +576,7 @@ public static string Command_SetBirthday_NotTimeTraveler { } /// - /// Looks up a localized string similar to Your birthday was set to <t:{0}:F>.. + /// Looks up a localized string similar to Your birthday was set to {0}. Your next birthday is <t:{1}:R>.. /// public static string Command_SetBirthday_Success { get { diff --git a/InstarBot/Strings.resx b/InstarBot/Strings.resx index 302548d..8db1437 100644 --- a/InstarBot/Strings.resx +++ b/InstarBot/Strings.resx @@ -318,8 +318,8 @@ As the user is a new member, their membership has automatically been withheld pe {0} is the user birthday timestamp, {1} is the minimum permissible age. - Your birthday was set to <t:{0}:F>. - {0} is the user birthday timestamp + Your birthday was set to {0}. Your next birthday is <t:{1}:R>. + {0} is the date (like "March 1st"), {2} is the timestamp of the user's next birthday. Your birthday could not be set at this time. Please try again later. diff --git a/InstarBot/Utilities.cs b/InstarBot/Utilities.cs index d4cb187..360261a 100644 --- a/InstarBot/Utilities.cs +++ b/InstarBot/Utilities.cs @@ -1,4 +1,5 @@ -using System.Globalization; +using System; +using System.Globalization; using System.Reflection; using System.Runtime.Serialization; @@ -174,4 +175,48 @@ public static string ToDynamoDBCompatibleDateTime(DateTime dateTime) var utc = dateTime.ToUniversalTime(); return utc.ToString(ISO8601DateFormat, CultureInfo.InvariantCulture); } + + /// + /// Returns the ordinal suffix for an integer. + /// + /// The integer. + /// The ordinal suffix of the integer, e.g. "st" if is 1. + public static string GetOrdinal(int integer) + { + return (integer % 100) switch + { + 11 or 12 or 13 => "th", + _ => (integer % 10) switch + { + 1 => "st", + 2 => "nd", + 3 => "rd", + _ => "th" + } + }; + } + + extension(DateTime dateTime) + { + public string ToString(string format, bool useExtendedSpecifiers) + { + return useExtendedSpecifiers + ? dateTime.ToString(format) + .Replace("nn", GetOrdinal(dateTime.Day).ToLower()) + .Replace("NN", GetOrdinal(dateTime.Day).ToUpper()) + : dateTime.ToString(format); + } + } + + extension(DateTimeOffset dateTimeOffset) + { + public string ToString(string format, bool useExtendedSpecifiers) + { + return useExtendedSpecifiers + ? dateTimeOffset.ToString(format) + .Replace("nn", GetOrdinal(dateTimeOffset.Day).ToLower()) + .Replace("NN", GetOrdinal(dateTimeOffset.Day).ToUpper()) + : dateTimeOffset.ToString(format); + } + } } \ No newline at end of file From fc2bc4fac8d124f21182298d1f8187b2cdbde0a1 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 29 Dec 2025 15:50:16 -0800 Subject: [PATCH 35/53] Updated command line options to permit log level setting at startup --- Commands/ApplicationStart/StartInstar.sh | 2 +- InstarBot/CommandLineOptions.cs | 12 ++++++--- InstarBot/Program.cs | 31 ++++++++---------------- InstarBot/Properties/launchSettings.json | 8 ++++++ 4 files changed, 28 insertions(+), 25 deletions(-) create mode 100644 InstarBot/Properties/launchSettings.json diff --git a/Commands/ApplicationStart/StartInstar.sh b/Commands/ApplicationStart/StartInstar.sh index b7086be..33b211a 100644 --- a/Commands/ApplicationStart/StartInstar.sh +++ b/Commands/ApplicationStart/StartInstar.sh @@ -12,7 +12,7 @@ startInstar() { local INSTAR_CONF="/Instar/bin/Config/$INSTAR_CONF_FILE" - /Instar/bin/InstarBot --config-path "$INSTAR_CONF" > /dev/null 2> /dev/null < /dev/null & + /Instar/bin/InstarBot --config-path "$INSTAR_CONF" --level Debug > /dev/null 2> /dev/null < /dev/null & } startInstar \ No newline at end of file diff --git a/InstarBot/CommandLineOptions.cs b/InstarBot/CommandLineOptions.cs index 0290bb4..1bca0c8 100644 --- a/InstarBot/CommandLineOptions.cs +++ b/InstarBot/CommandLineOptions.cs @@ -1,12 +1,18 @@ using CommandLine; using JetBrains.Annotations; +using Serilog.Events; namespace PaxAndromeda.Instar; [UsedImplicitly] public class CommandLineOptions { - [Option('c', "config-path", Required = false, Default = "Config/Instar.conf.json", HelpText = "Sets the configuration path.")] - [UsedImplicitly] - public string? ConfigPath { get; set; } + [Option('c', "config-path", Required = false, HelpText = "Sets the configuration path.")] + [UsedImplicitly] + public string? ConfigPath { get; set; } + + + [Option('l', "level", Required = false, Default = LogEventLevel.Information, HelpText = "Sets the log verbosity")] + [UsedImplicitly] + public LogEventLevel? LogLevel { get; set; } } \ No newline at end of file diff --git a/InstarBot/Program.cs b/InstarBot/Program.cs index ade8bf3..9d5e0e7 100644 --- a/InstarBot/Program.cs +++ b/InstarBot/Program.cs @@ -1,6 +1,6 @@ -using System.Diagnostics.CodeAnalysis; -using Amazon; +using Amazon; using Amazon.CloudWatchLogs; +using CommandLine; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using PaxAndromeda.Instar.Commands; @@ -9,10 +9,7 @@ using Serilog.Events; using Serilog.Formatting.Json; using Serilog.Sinks.AwsCloudWatch; - -#if !DEBUG -using CommandLine; -#endif +using System.Diagnostics.CodeAnalysis; namespace PaxAndromeda.Instar; @@ -31,19 +28,19 @@ public static async Task Main(string[] args) #if DEBUG var configPath = "Config/Instar.debug.conf.json"; #else - var cli = Parser.Default.ParseArguments(args).Value; - var configPath = "Config/Instar.conf.json"; - if (!string.IsNullOrEmpty(cli.ConfigPath)) - configPath = cli.ConfigPath; #endif + var cli = Parser.Default.ParseArguments(args).Value; + if (!string.IsNullOrEmpty(cli.ConfigPath)) + configPath = cli.ConfigPath; + Log.Information("Config path is {Path}", configPath); IConfiguration config = new ConfigurationBuilder() .AddJsonFile(configPath) .Build(); - InitializeLogger(config); + InitializeLogger(config, cli.LogLevel); Console.CancelKeyPress += StopSystem; await RunAsync(config); @@ -87,19 +84,11 @@ private static async Task RunAsync(IConfiguration config) Task.WaitAll(tasks); } - private static void InitializeLogger(IConfiguration config) + private static void InitializeLogger(IConfiguration config, LogEventLevel? requestedLogLevel) { -#if TRACE - const LogEventLevel minLevel = LogEventLevel.Verbose; -#elif DEBUG - const LogEventLevel minLevel = LogEventLevel.Verbose; -#else - const LogEventLevel minLevel = LogEventLevel.Information; -#endif - var logCfg = new LoggerConfiguration() .Enrich.FromLogContext() - .MinimumLevel.Is(minLevel) + .MinimumLevel.Is(requestedLogLevel ?? LogEventLevel.Information) .WriteTo.Console(); diff --git a/InstarBot/Properties/launchSettings.json b/InstarBot/Properties/launchSettings.json new file mode 100644 index 0000000..132133d --- /dev/null +++ b/InstarBot/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "InstarBot": { + "commandName": "Project", + "commandLineArgs": "--level Verbose" + } + } +} \ No newline at end of file From 9e4f99372de39749ac4adfc839c441b30dbbeebc Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Thu, 1 Jan 2026 00:07:42 -0800 Subject: [PATCH 36/53] Bugfix: only grant birthdays once for each runtime --- InstarBot/Services/BirthdaySystem.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/InstarBot/Services/BirthdaySystem.cs b/InstarBot/Services/BirthdaySystem.cs index 49e99d5..e2155a1 100644 --- a/InstarBot/Services/BirthdaySystem.cs +++ b/InstarBot/Services/BirthdaySystem.cs @@ -154,6 +154,12 @@ private async Task> GrantBirthdays(InstarDynamicConfiguration cf continue; } + if (guildUser.RoleIds.Contains(cfg.BirthdayConfig.BirthdayRole.ID)) + { + // already has the role, skip + continue; + } + toMention.Add(userId); await GrantBirthdayRole(cfg, guildUser, result.Data.Birthday); From 40368def99c1bd205c077845c1e0b44dbe78a0f2 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Thu, 1 Jan 2026 11:00:12 -0800 Subject: [PATCH 37/53] Bugfix: Auto kick spambot roles upon user join, not user update --- InstarBot/Services/AutoMemberSystem.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index dd4afe4..42b3047 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -151,7 +151,9 @@ private async Task HandleUserJoined(IGuildUser user) { var cfg = await _dynamicConfig.GetConfig(); - var dbUser = await _ddbService.GetUserAsync(user.Id); + await HandleAutoKickRoles(user, cfg); + + var dbUser = await _ddbService.GetUserAsync(user.Id); if (dbUser is null) { // Let's create a new user @@ -228,23 +230,28 @@ private async Task HandleUserUpdated(UserUpdatedEventArgs arg) await user.CommitAsync(); } + await HandleAutoKickRoles(arg.After); + } + + private async Task HandleAutoKickRoles(IGuildUser user, InstarDynamicConfiguration? cfg = null) + { // Does the user have any of the auto kick roles? try { - var cfg = await _dynamicConfig.GetConfig(); + cfg ??= await _dynamicConfig.GetConfig(); if (cfg.AutoKickRoles is null) return; - if (!cfg.AutoKickRoles.ContainsAny(arg.After.RoleIds.Select(n => new Snowflake(n)).ToArray())) + if (!cfg.AutoKickRoles.ContainsAny(user.RoleIds.Select(n => new Snowflake(n)).ToArray())) return; - await arg.After.KickAsync("Automatically kicked for having a forbidden role."); + await user.KickAsync("Automatically kicked for having a forbidden role."); await _metricService.Emit(Metric.AMS_ForbiddenRoleKicks, 1); } catch (Exception ex) { - Log.Error(ex, "Failed to determine if user {UserID} has any forbidden roles.", arg.ID.ID); + Log.Error(ex, "Failed to determine if user {UserID} has any forbidden roles.", user.Id); } } From 3289657a5149cb1db403c8c5647b2941173fcc92 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 09:41:30 -0800 Subject: [PATCH 38/53] Added additional logging to help diagnose test failure on Linux. --- .../Services/BirthdaySystemTests.cs | 5 +++++ InstarBot/Birthday.cs | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs index 6bb96e4..efd29eb 100644 --- a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs +++ b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs @@ -4,6 +4,7 @@ using Moq; using PaxAndromeda.Instar; using PaxAndromeda.Instar.Services; +using Serilog; using Xunit; using Metric = PaxAndromeda.Instar.Metrics.Metric; @@ -224,6 +225,10 @@ public static async Task BirthdaySystem_WithUserBirthdayStill_ShouldKeepBirthday var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); dbUser.Should().NotBeNull(); dbUser.Data.Birthday!.IsToday.Should().BeTrue(); + + // .IsToday seems to be failing on Github Actions for some reason. Unclear if this is + // a parallelization issue or something else. + Log.Information("Birthdate: {Birthdate}, Observed: {Observed}, IsToday: {IsToday}", dbUser.Data.Birthday!.Birthdate, dbUser.Data.Birthday!.Observed, dbUser.Data.Birthday!.IsToday); orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); diff --git a/InstarBot/Birthday.cs b/InstarBot/Birthday.cs index 99b5b22..c556803 100644 --- a/InstarBot/Birthday.cs +++ b/InstarBot/Birthday.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Serilog; namespace PaxAndromeda.Instar; @@ -93,9 +94,15 @@ public bool IsToday var utcOffset = Observed.Offset; var currentLocalTime = dtNow.ToOffset(utcOffset); + Log.Verbose("utcOffset = {utcOffset}", utcOffset); + Log.Verbose("currentLocalTime = {currentLocalTime}", currentLocalTime); + var localTimeToday = new DateTimeOffset(currentLocalTime.Date, currentLocalTime.Offset); var localTimeTomorrow = localTimeToday.Date.AddDays(1); + Log.Verbose("localTimeToday = {localTimeToday}", localTimeToday); + Log.Verbose("localTimeTomorrow = {localTimeTomorrow}", localTimeTomorrow); + return Observed >= localTimeToday && Observed < localTimeTomorrow; } } From 70f2f9a71aa479f2abfac91164dff8dcf6b73040 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 09:48:16 -0800 Subject: [PATCH 39/53] Moving the log line to a spot where it can actually run --- InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs index efd29eb..c3af6f3 100644 --- a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs +++ b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs @@ -224,12 +224,13 @@ public static async Task BirthdaySystem_WithUserBirthdayStill_ShouldKeepBirthday // Pre assert var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); dbUser.Should().NotBeNull(); - dbUser.Data.Birthday!.IsToday.Should().BeTrue(); // .IsToday seems to be failing on Github Actions for some reason. Unclear if this is // a parallelization issue or something else. Log.Information("Birthdate: {Birthdate}, Observed: {Observed}, IsToday: {IsToday}", dbUser.Data.Birthday!.Birthdate, dbUser.Data.Birthday!.Observed, dbUser.Data.Birthday!.IsToday); + dbUser.Data.Birthday!.IsToday.Should().BeTrue(); + orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); // Act From 83025cae13555d2f28d93afbd7fc7ec9332eb409 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 10:07:11 -0800 Subject: [PATCH 40/53] Added Xunit serilog sink for better log tracing. Also updated AWS SDK --- .../InstarBot.Tests.Common.csproj | 1 + .../InstarBot.Tests.Integration.csproj | 1 + .../AutoMemberSystemCommandTests.cs | 8 ++++++- .../CheckEligibilityCommandTests.cs | 8 ++++++- .../Interactions/PageCommandTests.cs | 10 +++++++-- .../Interactions/PingCommandTests.cs | 22 ++++++++++++------- .../Interactions/ReportUserTests.cs | 8 ++++++- .../Interactions/ResetBirthdayCommandTests.cs | 8 ++++++- .../Interactions/SetBirthdayCommandTests.cs | 8 ++++++- .../Services/AutoMemberSystemTests.cs | 8 ++++++- .../Services/BirthdaySystemTests.cs | 8 ++++++- .../Services/NotificationSystemTests.cs | 8 ++++++- .../InstarBot.Test.Framework.csproj | 5 +++++ .../TestOrchestrator.cs | 12 +++++----- .../InstarBot.Tests.Unit.csproj | 1 + InstarBot/InstarBot.csproj | 1 + 16 files changed, 92 insertions(+), 25 deletions(-) diff --git a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj index 6e8fa45..6271f3f 100644 --- a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj +++ b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj @@ -10,6 +10,7 @@ + diff --git a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj index 3021c4c..f0b9484 100644 --- a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj +++ b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj @@ -8,6 +8,7 @@ + diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs index 0c5ef3e..6a2ea13 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs @@ -10,11 +10,17 @@ using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Xunit; +using Xunit.Abstractions; namespace InstarBot.Tests.Integration.Interactions; -public static class AutoMemberSystemCommandTests +public class AutoMemberSystemCommandTests { + public AutoMemberSystemCommandTests(ITestOutputHelper testOutputHelper) + { + TestOrchestrator.SetupLogging(testOutputHelper); + } + [Fact] public static async Task HoldMember_WithValidUserAndReason_ShouldCreateRecord() { diff --git a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs index 6e3d2c1..382ab4b 100644 --- a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs @@ -9,11 +9,17 @@ using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; using Xunit; +using Xunit.Abstractions; namespace InstarBot.Tests.Integration.Interactions; -public static class CheckEligibilityCommandTests +public class CheckEligibilityCommandTests { + public CheckEligibilityCommandTests(ITestOutputHelper testOutputHelper) + { + TestOrchestrator.SetupLogging(testOutputHelper); + } + private static TestOrchestrator SetupOrchestrator(MembershipEligibility eligibility) { var orchestrator = TestOrchestrator.Default; diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index 6550506..ff71cb6 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -7,14 +7,20 @@ using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.ConfigModels; using Xunit; +using Xunit.Abstractions; namespace InstarBot.Tests.Integration.Interactions; -public static class PageCommandTests +public class PageCommandTests { private const string TestReason = "Test reason for paging"; - private static async Task SetupOrchestrator(PageCommandTestContext context) + public PageCommandTests(ITestOutputHelper testOutputHelper) + { + TestOrchestrator.SetupLogging(testOutputHelper); + } + + private static async Task SetupOrchestrator(PageCommandTestContext context) { var orchestrator = TestOrchestrator.Default; diff --git a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs index de207af..15cbc76 100644 --- a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs @@ -3,16 +3,22 @@ namespace InstarBot.Tests.Integration.Interactions; using Xunit; +using Xunit.Abstractions; -public static class PingCommandTests +public class PingCommandTests { - /// - /// Tests that the ping command emits an ephemeral "Pong!" response. - /// - /// This test verifies that calling the ping command results in the - /// expected ephemeral "Pong!" response. - /// - [Fact(DisplayName = "User should be able to issue the Ping command.")] + public PingCommandTests(ITestOutputHelper testOutputHelper) + { + TestOrchestrator.SetupLogging(testOutputHelper); + } + + /// + /// Tests that the ping command emits an ephemeral "Pong!" response. + /// + /// This test verifies that calling the ping command results in the + /// expected ephemeral "Pong!" response. + /// + [Fact(DisplayName = "User should be able to issue the Ping command.")] public static async Task PingCommand_Send_ShouldEmitEphemeralPong() { // Arrange diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs index d0988d0..7fe37fc 100644 --- a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -6,11 +6,17 @@ using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.Modals; using Xunit; +using Xunit.Abstractions; namespace InstarBot.Tests.Integration.Interactions; -public static class ReportUserTests +public class ReportUserTests { + public ReportUserTests(ITestOutputHelper testOutputHelper) + { + TestOrchestrator.SetupLogging(testOutputHelper); + } + private static TestOrchestrator SetupOrchestrator(ReportContext context) { var orchestrator = TestOrchestrator.Default; diff --git a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs index 4552f30..0d0965e 100644 --- a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs @@ -6,12 +6,18 @@ using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.DynamoModels; using Xunit; +using Xunit.Abstractions; using Assert = Xunit.Assert; namespace InstarBot.Tests.Integration.Interactions; -public static class ResetBirthdayCommandTests +public class ResetBirthdayCommandTests { + public ResetBirthdayCommandTests(ITestOutputHelper testOutputHelper) + { + TestOrchestrator.SetupLogging(testOutputHelper); + } + private static async Task SetupOrchestrator(DateTimeOffset? userBirthday = null, bool throwsError = false) { var orchestrator = TestOrchestrator.Default; diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs index 44313e6..9bf9cef 100644 --- a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -6,11 +6,17 @@ using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.Services; using Xunit; +using Xunit.Abstractions; namespace InstarBot.Tests.Integration.Interactions; -public static class SetBirthdayCommandTests +public class SetBirthdayCommandTests { + public SetBirthdayCommandTests(ITestOutputHelper testOutputHelper) + { + TestOrchestrator.SetupLogging(testOutputHelper); + } + private static async Task SetupOrchestrator(bool throwError = false) { var orchestrator = TestOrchestrator.Default; diff --git a/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs index c4cca37..112afc6 100644 --- a/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs +++ b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs @@ -9,10 +9,11 @@ using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Services; using Xunit; +using Xunit.Abstractions; namespace InstarBot.Tests.Integration.Services; -public static class AutoMemberSystemTests +public class AutoMemberSystemTests { private static readonly Snowflake NewMember = new(796052052433698817); private static readonly Snowflake Member = new(793611808372031499); @@ -21,6 +22,11 @@ public static class AutoMemberSystemTests private static readonly Snowflake SheHer = new(796578609535647765); private static readonly Snowflake AutoMemberHold = new(966434762032054282); + public AutoMemberSystemTests(ITestOutputHelper testOutputHelper) + { + TestOrchestrator.SetupLogging(testOutputHelper); + } + private static async Task SetupOrchestrator(AutoMemberSystemContext context) { var orchestrator = await TestOrchestrator.Builder diff --git a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs index c3af6f3..4eaff62 100644 --- a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs +++ b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs @@ -6,12 +6,18 @@ using PaxAndromeda.Instar.Services; using Serilog; using Xunit; +using Xunit.Abstractions; using Metric = PaxAndromeda.Instar.Metrics.Metric; namespace InstarBot.Tests.Integration.Services; -public static class BirthdaySystemTests +public class BirthdaySystemTests { + public BirthdaySystemTests(ITestOutputHelper testOutputHelper) + { + TestOrchestrator.SetupLogging(testOutputHelper); + } + private static async Task SetupOrchestrator(DateTimeOffset currentTime, DateTimeOffset? birthdate = null) { var orchestrator = await TestOrchestrator.Builder diff --git a/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs b/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs index 941bc4d..1b537c2 100644 --- a/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs +++ b/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs @@ -8,11 +8,17 @@ using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Xunit; +using Xunit.Abstractions; namespace InstarBot.Tests.Integration.Services; -public static class NotificationSystemTests +public class NotificationSystemTests { + public NotificationSystemTests(ITestOutputHelper testOutputHelper) + { + TestOrchestrator.SetupLogging(testOutputHelper); + } + private static async Task SetupOrchestrator() { return await TestOrchestrator.Builder diff --git a/InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj b/InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj index 76bb766..81d6f15 100644 --- a/InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj +++ b/InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj @@ -6,6 +6,11 @@ enable + + + + + diff --git a/InstarBot.Tests.Orchestrator/TestOrchestrator.cs b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs index 5db5933..1590ecd 100644 --- a/InstarBot.Tests.Orchestrator/TestOrchestrator.cs +++ b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs @@ -12,6 +12,7 @@ using Serilog.Events; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; +using Xunit.Abstractions; using InvalidOperationException = Amazon.CloudWatchLogs.Model.InvalidOperationException; namespace InstarBot.Test.Framework @@ -59,8 +60,6 @@ public IGuildUser Actor { internal TestOrchestrator(IServiceProvider serviceProvider, Snowflake actor) { - SetupLogging(); - _serviceProvider = serviceProvider; _actor = actor; @@ -70,8 +69,6 @@ internal TestOrchestrator(IServiceProvider serviceProvider, Snowflake actor) internal TestOrchestrator(IServiceProvider serviceProvider, Snowflake actor, TestGuildUser subject) { - SetupLogging(); - _serviceProvider = serviceProvider; _actor = actor; @@ -109,13 +106,14 @@ private void InitializeActor() tdbs.CreateUserAsync(InstarUserData.CreateFrom(user)).Wait(); } - private static void SetupLogging() + public static void SetupLogging(ITestOutputHelper testOutputHelper) { Log.Logger = new LoggerConfiguration() .Enrich.FromLogContext() .MinimumLevel.Is(LogEventLevel.Verbose) - .WriteTo.Console() - .CreateLogger(); + .WriteTo.TestOutput(testOutputHelper) + .CreateLogger() + .ForContext(); Log.Warning("Logging is enabled for this unit test."); } diff --git a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj index 4b438cd..bc1e067 100644 --- a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj +++ b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj @@ -11,6 +11,7 @@ + diff --git a/InstarBot/InstarBot.csproj b/InstarBot/InstarBot.csproj index 19d97bf..603c8c3 100644 --- a/InstarBot/InstarBot.csproj +++ b/InstarBot/InstarBot.csproj @@ -22,6 +22,7 @@ + From 4ce66946c0b6ce11ecf81717afa1c7a685fb4542 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 14:01:19 -0800 Subject: [PATCH 41/53] Updated Xunit2 to Xunit3 --- InstarBot.Tests.Common/EmbedVerifier.cs | 1 + InstarBot.Tests.Common/InstarBot.Tests.Common.csproj | 5 ++--- InstarBot.Tests.Integration/Assembly.cs | 1 - .../InstarBot.Tests.Integration.csproj | 5 ++--- .../Interactions/AutoMemberSystemCommandTests.cs | 5 ++--- .../Interactions/CheckEligibilityCommandTests.cs | 1 - .../Interactions/PageCommandTests.cs | 1 - .../Interactions/PingCommandTests.cs | 1 - .../Interactions/ReportUserTests.cs | 1 - .../Interactions/ResetBirthdayCommandTests.cs | 1 - .../Interactions/SetBirthdayCommandTests.cs | 1 - .../Services/AutoMemberSystemTests.cs | 1 - .../Services/BirthdaySystemTests.cs | 5 ++--- .../Services/NotificationSystemTests.cs | 1 - .../InstarBot.Test.Framework.csproj | 4 +++- InstarBot.Tests.Orchestrator/TestOrchestrator.cs | 5 +++-- InstarBot.Tests.Unit/Assembly.cs | 3 --- InstarBot.Tests.Unit/AsyncAutoResetEventTests.cs | 10 +++++----- InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj | 5 ++--- 19 files changed, 22 insertions(+), 35 deletions(-) delete mode 100644 InstarBot.Tests.Integration/Assembly.cs delete mode 100644 InstarBot.Tests.Unit/Assembly.cs diff --git a/InstarBot.Tests.Common/EmbedVerifier.cs b/InstarBot.Tests.Common/EmbedVerifier.cs index f22f65c..0ed5863 100644 --- a/InstarBot.Tests.Common/EmbedVerifier.cs +++ b/InstarBot.Tests.Common/EmbedVerifier.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using Discord; using Serilog; +using Xunit; namespace InstarBot.Tests; diff --git a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj index 6271f3f..fb541f3 100644 --- a/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj +++ b/InstarBot.Tests.Common/InstarBot.Tests.Common.csproj @@ -7,6 +7,7 @@ InstarBot.Tests false false + Exe @@ -14,14 +15,12 @@ - - - all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/InstarBot.Tests.Integration/Assembly.cs b/InstarBot.Tests.Integration/Assembly.cs deleted file mode 100644 index 4941ec7..0000000 --- a/InstarBot.Tests.Integration/Assembly.cs +++ /dev/null @@ -1 +0,0 @@ -[assembly: Parallelize] \ No newline at end of file diff --git a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj index f0b9484..7a51cf5 100644 --- a/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj +++ b/InstarBot.Tests.Integration/InstarBot.Tests.Integration.csproj @@ -4,6 +4,7 @@ net10.0 enable enable + Exe @@ -11,14 +12,12 @@ - - - all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs index 6a2ea13..f405d7f 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs @@ -10,7 +10,6 @@ using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Xunit; -using Xunit.Abstractions; namespace InstarBot.Tests.Integration.Interactions; @@ -155,7 +154,7 @@ await tds.CreateNotificationAsync(new Notification // There is a potential asynchronous delay here, so let's keep waiting for this condition for 5 seconds. await Task.WhenAny( - Task.Delay(5000), + Task.Delay(5000, TestContext.Current.CancellationToken), Task.Factory.StartNew(async () => { while (true) @@ -166,7 +165,7 @@ await Task.WhenAny( // only poll once every 50ms await Task.Delay(50); } - })); + }, TestContext.Current.CancellationToken)); } [Fact] diff --git a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs index 382ab4b..141b9d4 100644 --- a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs @@ -9,7 +9,6 @@ using PaxAndromeda.Instar.DynamoModels; using PaxAndromeda.Instar.Services; using Xunit; -using Xunit.Abstractions; namespace InstarBot.Tests.Integration.Interactions; diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index ff71cb6..bb06a0c 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -7,7 +7,6 @@ using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.ConfigModels; using Xunit; -using Xunit.Abstractions; namespace InstarBot.Tests.Integration.Interactions; diff --git a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs index 15cbc76..cb6717c 100644 --- a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs @@ -3,7 +3,6 @@ namespace InstarBot.Tests.Integration.Interactions; using Xunit; -using Xunit.Abstractions; public class PingCommandTests { diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs index 7fe37fc..a1ce8e1 100644 --- a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -6,7 +6,6 @@ using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.Modals; using Xunit; -using Xunit.Abstractions; namespace InstarBot.Tests.Integration.Interactions; diff --git a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs index 0d0965e..e3a29ab 100644 --- a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs @@ -6,7 +6,6 @@ using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.DynamoModels; using Xunit; -using Xunit.Abstractions; using Assert = Xunit.Assert; namespace InstarBot.Tests.Integration.Interactions; diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs index 9bf9cef..38f526a 100644 --- a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -6,7 +6,6 @@ using PaxAndromeda.Instar.Commands; using PaxAndromeda.Instar.Services; using Xunit; -using Xunit.Abstractions; namespace InstarBot.Tests.Integration.Interactions; diff --git a/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs index 112afc6..94a311c 100644 --- a/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs +++ b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs @@ -9,7 +9,6 @@ using PaxAndromeda.Instar.Modals; using PaxAndromeda.Instar.Services; using Xunit; -using Xunit.Abstractions; namespace InstarBot.Tests.Integration.Services; diff --git a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs index 4eaff62..59c06fb 100644 --- a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs +++ b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs @@ -6,7 +6,6 @@ using PaxAndromeda.Instar.Services; using Serilog; using Xunit; -using Xunit.Abstractions; using Metric = PaxAndromeda.Instar.Metrics.Metric; namespace InstarBot.Tests.Integration.Services; @@ -75,7 +74,7 @@ public static async Task BirthdaySystem_WhenUserBirthday_ShouldGrantRole() var channel = await orchestrator.Discord.GetChannel(orchestrator.Configuration.BirthdayConfig.BirthdayAnnounceChannel) as TestChannel; channel.Should().NotBeNull(); - var messages = await channel.GetMessagesAsync().SelectMany(n => n).ToListAsync(); + var messages = await channel.GetMessagesAsync().SelectMany(n => n).ToListAsync(TestContext.Current.CancellationToken); messages.Count.Should().BeGreaterThan(0); TestUtilities.MatchesFormat(Strings.Birthday_Announcement, messages[0].Content); @@ -160,7 +159,7 @@ public static async Task BirthdaySystem_WhenNoBirthdays_ShouldDoNothing() var channel = await orchestrator.Discord.GetChannel(orchestrator.Configuration.BirthdayConfig.BirthdayAnnounceChannel) as TestChannel; channel.Should().NotBeNull(); - var messages = await channel.GetMessagesAsync().SelectMany(n => n).ToListAsync(); + var messages = await channel.GetMessagesAsync().SelectMany(n => n).ToListAsync(TestContext.Current.CancellationToken); messages.Count.Should().Be(0); } diff --git a/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs b/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs index 1b537c2..70b1432 100644 --- a/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs +++ b/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs @@ -8,7 +8,6 @@ using PaxAndromeda.Instar.Metrics; using PaxAndromeda.Instar.Services; using Xunit; -using Xunit.Abstractions; namespace InstarBot.Tests.Integration.Services; diff --git a/InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj b/InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj index 81d6f15..d61f75d 100644 --- a/InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj +++ b/InstarBot.Tests.Orchestrator/InstarBot.Test.Framework.csproj @@ -4,11 +4,13 @@ net10.0 enable enable + Exe - + + diff --git a/InstarBot.Tests.Orchestrator/TestOrchestrator.cs b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs index 1590ecd..6229f64 100644 --- a/InstarBot.Tests.Orchestrator/TestOrchestrator.cs +++ b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs @@ -12,7 +12,8 @@ using Serilog.Events; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; -using Xunit.Abstractions; +using Serilog.Sinks.XUnit3; +using Xunit; using InvalidOperationException = Amazon.CloudWatchLogs.Model.InvalidOperationException; namespace InstarBot.Test.Framework @@ -111,7 +112,7 @@ public static void SetupLogging(ITestOutputHelper testOutputHelper) Log.Logger = new LoggerConfiguration() .Enrich.FromLogContext() .MinimumLevel.Is(LogEventLevel.Verbose) - .WriteTo.TestOutput(testOutputHelper) + .WriteTo.XUnit3TestOutput() .CreateLogger() .ForContext(); Log.Warning("Logging is enabled for this unit test."); diff --git a/InstarBot.Tests.Unit/Assembly.cs b/InstarBot.Tests.Unit/Assembly.cs deleted file mode 100644 index 5d68f78..0000000 --- a/InstarBot.Tests.Unit/Assembly.cs +++ /dev/null @@ -1,3 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; - -[assembly: Parallelize] \ No newline at end of file diff --git a/InstarBot.Tests.Unit/AsyncAutoResetEventTests.cs b/InstarBot.Tests.Unit/AsyncAutoResetEventTests.cs index de0ac0c..933ee7d 100644 --- a/InstarBot.Tests.Unit/AsyncAutoResetEventTests.cs +++ b/InstarBot.Tests.Unit/AsyncAutoResetEventTests.cs @@ -15,7 +15,7 @@ public void WaitAsync_CompletesImmediately_WhenInitiallySignaled() var ev = new AsyncAutoResetEvent(true); // Act - var task = ev.WaitAsync(); + var task = ev.WaitAsync(TestContext.Current.CancellationToken); // Assert task.IsCompleted.Should().BeTrue(); @@ -26,8 +26,8 @@ public void Set_ReleasesSingleWaiter() { var ev = new AsyncAutoResetEvent(false); - var waiter1 = ev.WaitAsync(); - var waiter2 = ev.WaitAsync(); + var waiter1 = ev.WaitAsync(TestContext.Current.CancellationToken); + var waiter2 = ev.WaitAsync(TestContext.Current.CancellationToken); // First Set should release only one waiter. ev.Set(); @@ -49,13 +49,13 @@ public void Set_MarksEventSignaled_WhenNoWaiters() // No waiters now — Set should mark the event signaled so the next WaitAsync completes immediately. ev.Set(); - var immediate = ev.WaitAsync(); + var immediate = ev.WaitAsync(TestContext.Current.CancellationToken); // WaitAsync should complete immediately after Set() when there were no waiters immediate.IsCompleted.Should().BeTrue(); // That consumption should reset the event; a subsequent waiter should block. - var next = ev.WaitAsync(); + var next = ev.WaitAsync(TestContext.Current.CancellationToken); next.IsCompleted.Should().BeFalse(); } diff --git a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj index bc1e067..92bf113 100644 --- a/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj +++ b/InstarBot.Tests.Unit/InstarBot.Tests.Unit.csproj @@ -7,6 +7,7 @@ false InstarBot.Tests + Exe @@ -15,9 +16,6 @@ - - - runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -26,6 +24,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + From b060221f5f7f32f2a3c18a39fa399a0bb5157eb5 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 14:03:59 -0800 Subject: [PATCH 42/53] Add back console logging --- InstarBot.Tests.Orchestrator/TestOrchestrator.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/InstarBot.Tests.Orchestrator/TestOrchestrator.cs b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs index 6229f64..542914e 100644 --- a/InstarBot.Tests.Orchestrator/TestOrchestrator.cs +++ b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs @@ -112,6 +112,7 @@ public static void SetupLogging(ITestOutputHelper testOutputHelper) Log.Logger = new LoggerConfiguration() .Enrich.FromLogContext() .MinimumLevel.Is(LogEventLevel.Verbose) + .WriteTo.Console() .WriteTo.XUnit3TestOutput() .CreateLogger() .ForContext(); From e7dd60e7f098499f474765f34f95673ce50f638a Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 14:13:44 -0800 Subject: [PATCH 43/53] Removed unnecessary SetupLogging calls --- .../Interactions/AutoMemberSystemCommandTests.cs | 7 +------ .../Interactions/CheckEligibilityCommandTests.cs | 7 +------ .../Interactions/PageCommandTests.cs | 7 +------ .../Interactions/PingCommandTests.cs | 7 +------ .../Interactions/ReportUserTests.cs | 7 +------ .../Interactions/ResetBirthdayCommandTests.cs | 7 +------ .../Interactions/SetBirthdayCommandTests.cs | 7 +------ .../Services/AutoMemberSystemTests.cs | 7 +------ .../Services/BirthdaySystemTests.cs | 7 +------ .../Services/NotificationSystemTests.cs | 7 +------ InstarBot.Tests.Orchestrator/TestOrchestrator.cs | 9 ++++++--- 11 files changed, 16 insertions(+), 63 deletions(-) diff --git a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs index f405d7f..4cb0fd8 100644 --- a/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/AutoMemberSystemCommandTests.cs @@ -13,13 +13,8 @@ namespace InstarBot.Tests.Integration.Interactions; -public class AutoMemberSystemCommandTests +public static class AutoMemberSystemCommandTests { - public AutoMemberSystemCommandTests(ITestOutputHelper testOutputHelper) - { - TestOrchestrator.SetupLogging(testOutputHelper); - } - [Fact] public static async Task HoldMember_WithValidUserAndReason_ShouldCreateRecord() { diff --git a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs index 141b9d4..6e3d2c1 100644 --- a/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/CheckEligibilityCommandTests.cs @@ -12,13 +12,8 @@ namespace InstarBot.Tests.Integration.Interactions; -public class CheckEligibilityCommandTests +public static class CheckEligibilityCommandTests { - public CheckEligibilityCommandTests(ITestOutputHelper testOutputHelper) - { - TestOrchestrator.SetupLogging(testOutputHelper); - } - private static TestOrchestrator SetupOrchestrator(MembershipEligibility eligibility) { var orchestrator = TestOrchestrator.Default; diff --git a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs index bb06a0c..96319f0 100644 --- a/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PageCommandTests.cs @@ -10,15 +10,10 @@ namespace InstarBot.Tests.Integration.Interactions; -public class PageCommandTests +public static class PageCommandTests { private const string TestReason = "Test reason for paging"; - public PageCommandTests(ITestOutputHelper testOutputHelper) - { - TestOrchestrator.SetupLogging(testOutputHelper); - } - private static async Task SetupOrchestrator(PageCommandTestContext context) { var orchestrator = TestOrchestrator.Default; diff --git a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs index cb6717c..542116b 100644 --- a/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/PingCommandTests.cs @@ -4,13 +4,8 @@ namespace InstarBot.Tests.Integration.Interactions; using Xunit; -public class PingCommandTests +public static class PingCommandTests { - public PingCommandTests(ITestOutputHelper testOutputHelper) - { - TestOrchestrator.SetupLogging(testOutputHelper); - } - /// /// Tests that the ping command emits an ephemeral "Pong!" response. /// diff --git a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs index a1ce8e1..d0988d0 100644 --- a/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ReportUserTests.cs @@ -9,13 +9,8 @@ namespace InstarBot.Tests.Integration.Interactions; -public class ReportUserTests +public static class ReportUserTests { - public ReportUserTests(ITestOutputHelper testOutputHelper) - { - TestOrchestrator.SetupLogging(testOutputHelper); - } - private static TestOrchestrator SetupOrchestrator(ReportContext context) { var orchestrator = TestOrchestrator.Default; diff --git a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs index e3a29ab..4552f30 100644 --- a/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/ResetBirthdayCommandTests.cs @@ -10,13 +10,8 @@ namespace InstarBot.Tests.Integration.Interactions; -public class ResetBirthdayCommandTests +public static class ResetBirthdayCommandTests { - public ResetBirthdayCommandTests(ITestOutputHelper testOutputHelper) - { - TestOrchestrator.SetupLogging(testOutputHelper); - } - private static async Task SetupOrchestrator(DateTimeOffset? userBirthday = null, bool throwsError = false) { var orchestrator = TestOrchestrator.Default; diff --git a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs index 38f526a..44313e6 100644 --- a/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs +++ b/InstarBot.Tests.Integration/Interactions/SetBirthdayCommandTests.cs @@ -9,13 +9,8 @@ namespace InstarBot.Tests.Integration.Interactions; -public class SetBirthdayCommandTests +public static class SetBirthdayCommandTests { - public SetBirthdayCommandTests(ITestOutputHelper testOutputHelper) - { - TestOrchestrator.SetupLogging(testOutputHelper); - } - private static async Task SetupOrchestrator(bool throwError = false) { var orchestrator = TestOrchestrator.Default; diff --git a/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs index 94a311c..c4cca37 100644 --- a/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs +++ b/InstarBot.Tests.Integration/Services/AutoMemberSystemTests.cs @@ -12,7 +12,7 @@ namespace InstarBot.Tests.Integration.Services; -public class AutoMemberSystemTests +public static class AutoMemberSystemTests { private static readonly Snowflake NewMember = new(796052052433698817); private static readonly Snowflake Member = new(793611808372031499); @@ -21,11 +21,6 @@ public class AutoMemberSystemTests private static readonly Snowflake SheHer = new(796578609535647765); private static readonly Snowflake AutoMemberHold = new(966434762032054282); - public AutoMemberSystemTests(ITestOutputHelper testOutputHelper) - { - TestOrchestrator.SetupLogging(testOutputHelper); - } - private static async Task SetupOrchestrator(AutoMemberSystemContext context) { var orchestrator = await TestOrchestrator.Builder diff --git a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs index 59c06fb..6af9f75 100644 --- a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs +++ b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs @@ -10,13 +10,8 @@ namespace InstarBot.Tests.Integration.Services; -public class BirthdaySystemTests +public static class BirthdaySystemTests { - public BirthdaySystemTests(ITestOutputHelper testOutputHelper) - { - TestOrchestrator.SetupLogging(testOutputHelper); - } - private static async Task SetupOrchestrator(DateTimeOffset currentTime, DateTimeOffset? birthdate = null) { var orchestrator = await TestOrchestrator.Builder diff --git a/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs b/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs index 70b1432..941bc4d 100644 --- a/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs +++ b/InstarBot.Tests.Integration/Services/NotificationSystemTests.cs @@ -11,13 +11,8 @@ namespace InstarBot.Tests.Integration.Services; -public class NotificationSystemTests +public static class NotificationSystemTests { - public NotificationSystemTests(ITestOutputHelper testOutputHelper) - { - TestOrchestrator.SetupLogging(testOutputHelper); - } - private static async Task SetupOrchestrator() { return await TestOrchestrator.Builder diff --git a/InstarBot.Tests.Orchestrator/TestOrchestrator.cs b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs index 542914e..9e59670 100644 --- a/InstarBot.Tests.Orchestrator/TestOrchestrator.cs +++ b/InstarBot.Tests.Orchestrator/TestOrchestrator.cs @@ -61,6 +61,8 @@ public IGuildUser Actor { internal TestOrchestrator(IServiceProvider serviceProvider, Snowflake actor) { + SetupLogging(); + _serviceProvider = serviceProvider; _actor = actor; @@ -70,6 +72,8 @@ internal TestOrchestrator(IServiceProvider serviceProvider, Snowflake actor) internal TestOrchestrator(IServiceProvider serviceProvider, Snowflake actor, TestGuildUser subject) { + SetupLogging(); + _serviceProvider = serviceProvider; _actor = actor; @@ -107,15 +111,14 @@ private void InitializeActor() tdbs.CreateUserAsync(InstarUserData.CreateFrom(user)).Wait(); } - public static void SetupLogging(ITestOutputHelper testOutputHelper) + public static void SetupLogging() { Log.Logger = new LoggerConfiguration() .Enrich.FromLogContext() .MinimumLevel.Is(LogEventLevel.Verbose) .WriteTo.Console() .WriteTo.XUnit3TestOutput() - .CreateLogger() - .ForContext(); + .CreateLogger(); Log.Warning("Logging is enabled for this unit test."); } From d7f988f7087b71e8e74450df6ba9f081158fcbb8 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 14:42:01 -0800 Subject: [PATCH 44/53] Fixed bug with birthday role being removed while still a user's birthday --- .../Services/BirthdaySystemTests.cs | 7 +------ InstarBot/Birthday.cs | 8 +------- InstarBot/Services/BirthdaySystem.cs | 15 --------------- 3 files changed, 2 insertions(+), 28 deletions(-) diff --git a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs index 6af9f75..2fb3c8e 100644 --- a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs +++ b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs @@ -211,7 +211,7 @@ public static async Task BirthdaySystem_WithBirthdayRoleButNoBirthdayRecord_Shou public static async Task BirthdaySystem_WithUserBirthdayStill_ShouldKeepBirthdayRoles() { // Arrange - var birthday = DateTime.Parse("2000-02-13T12:00:00Z"); + var birthday = DateTime.Parse("2000-02-13T00:00:00-08:00"); var currentTime = DateTime.Parse("2025-02-14T00:00:00Z"); var orchestrator = await SetupOrchestrator(currentTime, birthday); @@ -224,11 +224,6 @@ public static async Task BirthdaySystem_WithUserBirthdayStill_ShouldKeepBirthday // Pre assert var dbUser = await orchestrator.Database.GetUserAsync(orchestrator.Subject.Id); dbUser.Should().NotBeNull(); - - // .IsToday seems to be failing on Github Actions for some reason. Unclear if this is - // a parallelization issue or something else. - Log.Information("Birthdate: {Birthdate}, Observed: {Observed}, IsToday: {IsToday}", dbUser.Data.Birthday!.Birthdate, dbUser.Data.Birthday!.Observed, dbUser.Data.Birthday!.IsToday); - dbUser.Data.Birthday!.IsToday.Should().BeTrue(); orchestrator.Subject.RoleIds.Should().Contain(orchestrator.Configuration.BirthdayConfig.BirthdayRole); diff --git a/InstarBot/Birthday.cs b/InstarBot/Birthday.cs index c556803..163b7df 100644 --- a/InstarBot/Birthday.cs +++ b/InstarBot/Birthday.cs @@ -94,14 +94,8 @@ public bool IsToday var utcOffset = Observed.Offset; var currentLocalTime = dtNow.ToOffset(utcOffset); - Log.Verbose("utcOffset = {utcOffset}", utcOffset); - Log.Verbose("currentLocalTime = {currentLocalTime}", currentLocalTime); - var localTimeToday = new DateTimeOffset(currentLocalTime.Date, currentLocalTime.Offset); - var localTimeTomorrow = localTimeToday.Date.AddDays(1); - - Log.Verbose("localTimeToday = {localTimeToday}", localTimeToday); - Log.Verbose("localTimeTomorrow = {localTimeTomorrow}", localTimeTomorrow); + var localTimeTomorrow = localTimeToday.AddDays(1); return Observed >= localTimeToday && Observed < localTimeTomorrow; } diff --git a/InstarBot/Services/BirthdaySystem.cs b/InstarBot/Services/BirthdaySystem.cs index 8895989..6165757 100644 --- a/InstarBot/Services/BirthdaySystem.cs +++ b/InstarBot/Services/BirthdaySystem.cs @@ -97,21 +97,6 @@ private async Task RemoveBirthdays(InstarDynamicConfiguration cfg, DateTime curr toRemove.Add(user.Data.UserID!); continue; } - - var birthDate = user.Data.Birthday.Birthdate; - - var thisYearBirthday = new DateTime( - currentTime.Year, - birthDate.Month, birthDate.Day, birthDate.Hour, birthDate.Minute, 0, DateTimeKind.Utc); - - if (thisYearBirthday > currentTime) - thisYearBirthday = thisYearBirthday.AddYears(-1); - - if (currentTime - thisYearBirthday >= TimeSpan.FromDays(1)) - { - Log.Information("Removing birthday role from {UserID} due to their birthday not being today. CurrentTime={CurrentTime}, ThisYearBirthday={ThisYearBirthday}, Diff={TimeDiff}", user.Data.UserID, currentTime, thisYearBirthday, currentTime - thisYearBirthday); - toRemove.Add(user.Data.UserID!); - } } foreach (var snowflake in toRemove) From 4e2bb7bf7f04bf9a01a38705dfa90e77a5e04421 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 14:46:25 -0800 Subject: [PATCH 45/53] Works on my machine --- InstarBot/Birthday.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/InstarBot/Birthday.cs b/InstarBot/Birthday.cs index 163b7df..1788e92 100644 --- a/InstarBot/Birthday.cs +++ b/InstarBot/Birthday.cs @@ -94,9 +94,16 @@ public bool IsToday var utcOffset = Observed.Offset; var currentLocalTime = dtNow.ToOffset(utcOffset); + Log.Verbose("dtNow = {dtNow}", dtNow); + Log.Verbose("utcOffset = {utcOffset}", utcOffset); + Log.Verbose("currentLocalTime = {currentLocalTime}", currentLocalTime); + var localTimeToday = new DateTimeOffset(currentLocalTime.Date, currentLocalTime.Offset); var localTimeTomorrow = localTimeToday.AddDays(1); + Log.Verbose("localTimeToday = {localTimeToday}", localTimeToday); + Log.Verbose("localTimeTomorrow = {localTimeTomorrow}", localTimeTomorrow); + return Observed >= localTimeToday && Observed < localTimeTomorrow; } } From 09d1510a19e0f0967e6ab2a6e5aa15333d886c11 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 14:51:07 -0800 Subject: [PATCH 46/53] More logs --- InstarBot/Birthday.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/InstarBot/Birthday.cs b/InstarBot/Birthday.cs index 1788e92..4c4ade0 100644 --- a/InstarBot/Birthday.cs +++ b/InstarBot/Birthday.cs @@ -24,6 +24,9 @@ public DateTimeOffset Observed get { var birthdayNormalized = Normalize(Birthdate); + + Log.Verbose("Birthdate = {Birthdate}", Birthdate); + Log.Verbose("birthdayNormalized = {birthdayNormalized}", birthdayNormalized); var now = TimeProvider.GetUtcNow().ToOffset(birthdayNormalized.Offset); return new DateTimeOffset(now.Year, birthdayNormalized.Month, birthdayNormalized.Day, @@ -95,6 +98,7 @@ public bool IsToday var currentLocalTime = dtNow.ToOffset(utcOffset); Log.Verbose("dtNow = {dtNow}", dtNow); + Log.Verbose("observed = {observed}", Observed); Log.Verbose("utcOffset = {utcOffset}", utcOffset); Log.Verbose("currentLocalTime = {currentLocalTime}", currentLocalTime); From b5345281bf25104e3e6e988f1e09ba43aa05ed30 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 14:57:34 -0800 Subject: [PATCH 47/53] Fixed DateTime parsing issue in test --- InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs index 2fb3c8e..0ac5765 100644 --- a/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs +++ b/InstarBot.Tests.Integration/Services/BirthdaySystemTests.cs @@ -211,8 +211,8 @@ public static async Task BirthdaySystem_WithBirthdayRoleButNoBirthdayRecord_Shou public static async Task BirthdaySystem_WithUserBirthdayStill_ShouldKeepBirthdayRoles() { // Arrange - var birthday = DateTime.Parse("2000-02-13T00:00:00-08:00"); - var currentTime = DateTime.Parse("2025-02-14T00:00:00Z"); + var birthday = DateTimeOffset.Parse("2000-02-13T00:00:00-08:00"); + var currentTime = DateTimeOffset.Parse("2025-02-14T00:00:00Z"); var orchestrator = await SetupOrchestrator(currentTime, birthday); var system = orchestrator.GetService(); From 638827491b922baa323b0c6dee663a65368c24f1 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 15:00:54 -0800 Subject: [PATCH 48/53] Removed extra logging --- InstarBot/Birthday.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/InstarBot/Birthday.cs b/InstarBot/Birthday.cs index 4c4ade0..163b7df 100644 --- a/InstarBot/Birthday.cs +++ b/InstarBot/Birthday.cs @@ -24,9 +24,6 @@ public DateTimeOffset Observed get { var birthdayNormalized = Normalize(Birthdate); - - Log.Verbose("Birthdate = {Birthdate}", Birthdate); - Log.Verbose("birthdayNormalized = {birthdayNormalized}", birthdayNormalized); var now = TimeProvider.GetUtcNow().ToOffset(birthdayNormalized.Offset); return new DateTimeOffset(now.Year, birthdayNormalized.Month, birthdayNormalized.Day, @@ -97,17 +94,9 @@ public bool IsToday var utcOffset = Observed.Offset; var currentLocalTime = dtNow.ToOffset(utcOffset); - Log.Verbose("dtNow = {dtNow}", dtNow); - Log.Verbose("observed = {observed}", Observed); - Log.Verbose("utcOffset = {utcOffset}", utcOffset); - Log.Verbose("currentLocalTime = {currentLocalTime}", currentLocalTime); - var localTimeToday = new DateTimeOffset(currentLocalTime.Date, currentLocalTime.Offset); var localTimeTomorrow = localTimeToday.AddDays(1); - Log.Verbose("localTimeToday = {localTimeToday}", localTimeToday); - Log.Verbose("localTimeTomorrow = {localTimeTomorrow}", localTimeTomorrow); - return Observed >= localTimeToday && Observed < localTimeTomorrow; } } From 909a1ed46f230792cb251bd2041c7057b21cb05d Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 15:07:05 -0800 Subject: [PATCH 49/53] NotificationService will no longer send a notification if the referenced user is no longer on the server --- InstarBot/Services/NotificationService.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/InstarBot/Services/NotificationService.cs b/InstarBot/Services/NotificationService.cs index e438c6e..8a6a45a 100644 --- a/InstarBot/Services/NotificationService.cs +++ b/InstarBot/Services/NotificationService.cs @@ -64,6 +64,17 @@ private async Task ProcessNotification(InstarDatabaseEntry n var actor = discordService.GetUser(notification.Data.Actor); + // Don't post a notification if the reference user (if set) is not on the server. + if (notification.Data.ReferenceUser is not null) + { + var referenceUser = discordService.GetUser(notification.Data.ReferenceUser); + if (referenceUser is null) + { + Log.Warning("Reference user {ReferenceUserId} for notification dated {NotificationDate} not found on the server; skipping notification", notification.Data.ReferenceUser, notification.Data.Date); + return true; + } + } + Log.Debug("Actor ID {UserId} name: {Username}", notification.Data.Actor.ID, actor?.Username ?? ""); var embed = new NotificationEmbed(notification.Data, actor, cfg); From ddaf299229de82b52687be4aa2e1c534887d0678 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 15:08:40 -0800 Subject: [PATCH 50/53] Birthday system will no longer attempt to grant roles to a user who just left. --- InstarBot/Services/BirthdaySystem.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/InstarBot/Services/BirthdaySystem.cs b/InstarBot/Services/BirthdaySystem.cs index 6165757..6d030ef 100644 --- a/InstarBot/Services/BirthdaySystem.cs +++ b/InstarBot/Services/BirthdaySystem.cs @@ -36,6 +36,8 @@ public override async Task RunAsync() var cfg = await dynamicConfig.GetConfig(); var currentTime = _timeProvider.GetUtcNow().UtcDateTime; + await discord.SyncUsers(); + await RemoveBirthdays(cfg, currentTime); var successfulAdds = await GrantBirthdays(cfg, currentTime); From 0c34ec267f9d76c1a2b63e4330abc8f916706372 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 15:25:06 -0800 Subject: [PATCH 51/53] Added more comprehensive user update support --- InstarBot/Modals/UserUpdatedEventArgs.cs | 8 ++++---- InstarBot/Services/AutoMemberSystem.cs | 15 +++++++++++---- InstarBot/Services/DiscordService.cs | 21 ++++++++++++++++----- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/InstarBot/Modals/UserUpdatedEventArgs.cs b/InstarBot/Modals/UserUpdatedEventArgs.cs index 0c0a2e1..dd462f7 100644 --- a/InstarBot/Modals/UserUpdatedEventArgs.cs +++ b/InstarBot/Modals/UserUpdatedEventArgs.cs @@ -2,18 +2,18 @@ namespace PaxAndromeda.Instar.Modals; -public class UserUpdatedEventArgs(Snowflake id, IGuildUser before, IGuildUser after) +public class UserUpdatedEventArgs(Snowflake id, IUser before, IUser after) { public Snowflake ID { get; } = id; - public IGuildUser Before { get; } = before; + public IUser Before { get; } = before; - public IGuildUser After { get; } = after; + public IUser After { get; } = after; // TODO: add additional parts to this for the data we care about /// /// A flag indicating whether data we care about (e.g. nicknames, usernames) has changed. /// - public bool HasUpdated => Before.Username != After.Username || Before.Nickname != After.Nickname; + public bool HasUpdated => Before.Username != After.Username || (Before is IGuildUser gBefore && After is IGuildUser gAfter && gBefore.Nickname != gAfter.Nickname); } \ No newline at end of file diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index a367fa5..3b85d4d 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -192,13 +192,17 @@ private async Task HandleUserUpdated(UserUpdatedEventArgs arg) if (!arg.HasUpdated) return; + if (arg.After is not IGuildUser gAfter) + return; + var user = await _ddbService.GetUserAsync(arg.ID); if (user is null) { + // new user for the database, create from the latest data and return try { - await _ddbService.CreateUserAsync(InstarUserData.CreateFrom(arg.After)); + await _ddbService.CreateUserAsync(InstarUserData.CreateFrom(gAfter)); Log.Information("Created new user {Username} (user ID {UserID})", arg.After.Username, arg.ID); } catch (Exception ex) @@ -217,10 +221,13 @@ private async Task HandleUserUpdated(UserUpdatedEventArgs arg) user.Data.Username = arg.After.Username; changed = true; } + + if (arg.Before is not IGuildUser gBefore) + return; - if (arg.Before.Nickname != arg.After.Nickname) + if (gBefore.Nickname != gAfter.Nickname) { - user.Data.Nicknames?.Add(new InstarUserDataHistoricalEntry(_timeProvider.GetUtcNow().UtcDateTime, arg.After.Nickname)); + user.Data.Nicknames?.Add(new InstarUserDataHistoricalEntry(_timeProvider.GetUtcNow().UtcDateTime, gAfter.Nickname)); changed = true; } @@ -230,7 +237,7 @@ private async Task HandleUserUpdated(UserUpdatedEventArgs arg) await user.CommitAsync(); } - await HandleAutoKickRoles(arg.After); + await HandleAutoKickRoles(gAfter); } private async Task HandleAutoKickRoles(IGuildUser user, InstarDynamicConfiguration? cfg = null) diff --git a/InstarBot/Services/DiscordService.cs b/InstarBot/Services/DiscordService.cs index 3854682..e3f19f0 100644 --- a/InstarBot/Services/DiscordService.cs +++ b/InstarBot/Services/DiscordService.cs @@ -87,6 +87,7 @@ public DiscordService(IServiceProvider provider, IConfiguration config, IDynamic _socketClient.MessageDeleted += async (msgCache, _) => await _messageDeletedEvent.Invoke(msgCache.Id); _socketClient.UserJoined += async user => await _userJoinedEvent.Invoke(user); _socketClient.UserLeft += async (_, user) => await _userLeftEvent.Invoke(user); + _socketClient.UserUpdated += HandleUserUpdate; _socketClient.GuildMemberUpdated += HandleUserUpdate; _interactionService.Log += HandleDiscordLog; @@ -97,19 +98,29 @@ public DiscordService(IServiceProvider provider, IConfiguration config, IDynamic // Validate if (_guild == 0) throw new ConfigurationException("TargetGuild is not set"); - } + } - private async Task HandleUserUpdate(Cacheable before, SocketGuildUser after) + private Task HandleUserUpdate(Cacheable before, SocketGuildUser after) { // Can't do anything if we don't have a before state. // In the case of a user join, that is handled in UserJoined event. if (!before.HasValue) - return; + return Task.CompletedTask; + + return HandleUserUpdate(before.Value, after); + } - await _userUpdatedEvent.Invoke(new UserUpdatedEventArgs(after.Id, before.Value, after)); + private async Task HandleUserUpdate(SocketGuildUser before, SocketGuildUser after) + { + await _userUpdatedEvent.Invoke(new UserUpdatedEventArgs(after.Id, before, after)); + } + + private async Task HandleUserUpdate(SocketUser before, SocketUser after) + { + await _userUpdatedEvent.Invoke(new UserUpdatedEventArgs(after.Id, before, after)); } - private async Task HandleMessageCommand(SocketMessageCommand arg) + private async Task HandleMessageCommand(SocketMessageCommand arg) { Log.Information("Message command: {CommandName}", arg.CommandName); From a3c13112fc0e03a52c39bf1bcf945225373a63a2 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 15:33:36 -0800 Subject: [PATCH 52/53] Added configuration for allowed punishment reasons --- InstarBot/ConfigModels/AutoMemberConfig.cs | 3 ++- InstarBot/Services/AutoMemberSystem.cs | 31 +++++++++++++--------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/InstarBot/ConfigModels/AutoMemberConfig.cs b/InstarBot/ConfigModels/AutoMemberConfig.cs index fa94058..44372e1 100644 --- a/InstarBot/ConfigModels/AutoMemberConfig.cs +++ b/InstarBot/ConfigModels/AutoMemberConfig.cs @@ -14,7 +14,8 @@ public sealed class AutoMemberConfig public int MinimumMessages { get; init; } public int MinimumMessageTime { get; init; } public List RequiredRoles { get; init; } = null!; - public bool EnableGaiusCheck { get; init; } + public bool EnableGaiusCheck { get; init; } + public List AllowedPunishmentReasons { get; init; } = null!; } [UsedImplicitly] diff --git a/InstarBot/Services/AutoMemberSystem.cs b/InstarBot/Services/AutoMemberSystem.cs index 3b85d4d..a094ce1 100644 --- a/InstarBot/Services/AutoMemberSystem.cs +++ b/InstarBot/Services/AutoMemberSystem.cs @@ -64,22 +64,23 @@ internal override async Task Initialize() await PreloadGaiusPunishments(); } - /// - /// Filters warnings and caselogs to only focus on the ones we care about. For example, we don't - /// want to withhold membership from someone who was kicked for having a too new Discord account. - /// - /// A collection of warnings retrieved from the Gaius API. - /// A collection of caselogs retrieved from the Gaius API. - /// A tuple containing the filtered warnings and caselogs, respectively. - /// If or is null. - private static (IEnumerable, IEnumerable) FilterPunishments(IEnumerable warnings, IEnumerable caselogs) + /// + /// Filters warnings and caselogs to only focus on the ones we care about. For example, we don't + /// want to withhold membership from someone who was kicked for having a too new Discord account. + /// + /// The current configuration version in use. + /// A collection of warnings retrieved from the Gaius API. + /// A collection of caselogs retrieved from the Gaius API. + /// A tuple containing the filtered warnings and caselogs, respectively. + /// If or is null. + private static (IEnumerable, IEnumerable) FilterPunishments(InstarDynamicConfiguration cfg, IEnumerable warnings, IEnumerable caselogs) { if (warnings is null) throw new ArgumentNullException(nameof(warnings)); if (caselogs is null) throw new ArgumentNullException(nameof(caselogs)); - - var filteredCaselogs = caselogs.Where(n => n is not { Type: CaselogType.Kick, Reason: "Join age punishment" }); + + var filteredCaselogs = caselogs.Where(n => n.Type != CaselogType.Kick && !cfg.AutoMemberConfig.AllowedPunishmentReasons.Contains(n.Reason)); return (warnings, filteredCaselogs); } @@ -92,9 +93,11 @@ private async Task UpdateGaiusPunishments() // bias for an hour and a half ago. var afterTime = _timeProvider.GetUtcNow().UtcDateTime - TimeSpan.FromHours(1.5); + var cfg = await _dynamicConfig.GetConfig(); + try { - var (warnings, caselogs) = FilterPunishments( + var (warnings, caselogs) = FilterPunishments(cfg, await _gaiusApiService.GetWarningsAfter(afterTime), await _gaiusApiService.GetCaselogsAfter(afterTime)); @@ -480,9 +483,11 @@ private Dictionary GetMessagesSent() private async Task PreloadGaiusPunishments() { + var cfg = await _dynamicConfig.GetConfig(); + try { - var (warnings, caselogs) = FilterPunishments( + var (warnings, caselogs) = FilterPunishments(cfg, await _gaiusApiService.GetAllWarnings(), await _gaiusApiService.GetAllCaselogs()); From 81f22f91b0b4465712d8c82bc96426c32bbdeb44 Mon Sep 17 00:00:00 2001 From: PaxAndromeda Date: Mon, 9 Feb 2026 15:50:22 -0800 Subject: [PATCH 53/53] Minor bugfix --- InstarBot/ConfigModels/AutoMemberConfig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InstarBot/ConfigModels/AutoMemberConfig.cs b/InstarBot/ConfigModels/AutoMemberConfig.cs index 44372e1..8be803f 100644 --- a/InstarBot/ConfigModels/AutoMemberConfig.cs +++ b/InstarBot/ConfigModels/AutoMemberConfig.cs @@ -15,7 +15,7 @@ public sealed class AutoMemberConfig public int MinimumMessageTime { get; init; } public List RequiredRoles { get; init; } = null!; public bool EnableGaiusCheck { get; init; } - public List AllowedPunishmentReasons { get; init; } = null!; + public List AllowedPunishmentReasons { get; init; } = []; } [UsedImplicitly]