From a651e6ba46fc489d6044e0f2df711cacd64dd8bf Mon Sep 17 00:00:00 2001 From: "Austin S." Date: Sun, 6 Oct 2024 21:08:43 -0700 Subject: [PATCH] Add job scheduler for DeleteTempReply (#212) --- .../Commands/TempReplies/DeleteTempReply.cs | 101 ++++++++++ .../Commands/TempReplies/SendTempReply.cs | 77 ++------ .../Jobs/JobExtensions.cs | 9 + src/DiscordTranslationBot/Jobs/Scheduler.cs | 101 ++++++++++ .../Jobs/SchedulerBackgroundService.cs | 65 ++++++ src/DiscordTranslationBot/Program.cs | 4 +- .../.editorconfig | 6 + .../DeleteTempReplyHandlerTests.cs | 187 ++++++++++++++++++ .../TempReplies/DeleteTempReplyTests.cs | 59 ++++++ .../TempReplies/SendTempReplyHandlerTests.cs | 90 ++++----- .../Jobs/SchedulerTests.cs | 122 ++++++++++++ 11 files changed, 713 insertions(+), 108 deletions(-) create mode 100644 src/DiscordTranslationBot/Commands/TempReplies/DeleteTempReply.cs create mode 100644 src/DiscordTranslationBot/Jobs/JobExtensions.cs create mode 100644 src/DiscordTranslationBot/Jobs/Scheduler.cs create mode 100644 src/DiscordTranslationBot/Jobs/SchedulerBackgroundService.cs create mode 100644 tests/DiscordTranslationBot.Tests.Unit/Commands/TempReplies/DeleteTempReplyHandlerTests.cs create mode 100644 tests/DiscordTranslationBot.Tests.Unit/Commands/TempReplies/DeleteTempReplyTests.cs create mode 100644 tests/DiscordTranslationBot.Tests.Unit/Jobs/SchedulerTests.cs diff --git a/src/DiscordTranslationBot/Commands/TempReplies/DeleteTempReply.cs b/src/DiscordTranslationBot/Commands/TempReplies/DeleteTempReply.cs new file mode 100644 index 0000000..506a495 --- /dev/null +++ b/src/DiscordTranslationBot/Commands/TempReplies/DeleteTempReply.cs @@ -0,0 +1,101 @@ +using System.ComponentModel.DataAnnotations; +using Discord; +using Discord.Net; +using DiscordTranslationBot.Discord.Models; + +namespace DiscordTranslationBot.Commands.TempReplies; + +/// +/// Deletes a temp reply. +/// If there is a reaction associated with the source message, it will be cleared, too. +/// +public sealed class DeleteTempReply : ICommand +{ + /// + /// The temp reply to delete. + /// + [Required] + public required IUserMessage Reply { get; init; } + + /// + /// The source message ID that the temp reply is associated with. + /// + public required ulong SourceMessageId { get; init; } + + /// + /// The reaction associated with the source message, if any. + /// + public ReactionInfo? ReactionInfo { get; init; } +} + +public sealed partial class DeleteTempReplyHandler : ICommandHandler +{ + private readonly Log _log; + + public DeleteTempReplyHandler(ILogger logger) + { + _log = new Log(logger); + } + + public async ValueTask Handle(DeleteTempReply command, CancellationToken cancellationToken) + { + try + { + // If there is also a reaction and the source message still exists, remove the reaction from it. + if (command.ReactionInfo is not null) + { + var sourceMessage = await command.Reply.Channel.GetMessageAsync( + command.SourceMessageId, + options: new RequestOptions { CancelToken = cancellationToken }); + + if (sourceMessage is not null) + { + await sourceMessage.RemoveReactionAsync( + command.ReactionInfo.Emote, + command.ReactionInfo.UserId, + new RequestOptions { CancelToken = cancellationToken }); + } + } + + // Delete the reply message. + try + { + await command.Reply.DeleteAsync(new RequestOptions { CancelToken = cancellationToken }); + _log.DeletedTempMessage(command.Reply.Id); + } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.UnknownMessage) + { + // The message was likely already deleted. + _log.TempMessageNotFound(command.Reply.Id); + } + } + catch (Exception ex) + { + _log.FailedToDeleteTempMessage(ex, command.Reply.Id); + throw; + } + + return Unit.Value; + } + + private sealed partial class Log + { + private readonly ILogger _logger; + + public Log(ILogger logger) + { + _logger = logger; + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Deleted temp message ID {replyId}.")] + public partial void DeletedTempMessage(ulong replyId); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Temp message ID {replyId} was not found and likely manually deleted.")] + public partial void TempMessageNotFound(ulong replyId); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to delete temp message ID {replyId}.")] + public partial void FailedToDeleteTempMessage(Exception ex, ulong replyId); + } +} diff --git a/src/DiscordTranslationBot/Commands/TempReplies/SendTempReply.cs b/src/DiscordTranslationBot/Commands/TempReplies/SendTempReply.cs index 77d4844..5419dbd 100644 --- a/src/DiscordTranslationBot/Commands/TempReplies/SendTempReply.cs +++ b/src/DiscordTranslationBot/Commands/TempReplies/SendTempReply.cs @@ -1,8 +1,7 @@ using System.ComponentModel.DataAnnotations; using Discord; -using Discord.Net; using DiscordTranslationBot.Discord.Models; -using IMessage = Discord.IMessage; +using DiscordTranslationBot.Jobs; namespace DiscordTranslationBot.Commands.TempReplies; @@ -45,13 +44,16 @@ public sealed class SendTempReply : ICommand public sealed partial class SendTempReplyHandler : ICommandHandler { private readonly Log _log; + private readonly IScheduler _scheduler; /// /// Initializes a new instance of the class. /// + /// Scheduler to use. /// Logger to use. - public SendTempReplyHandler(ILogger logger) + public SendTempReplyHandler(IScheduler scheduler, ILogger logger) { + _scheduler = scheduler; _log = new Log(logger); } @@ -84,55 +86,18 @@ public async ValueTask Handle(SendTempReply command, CancellationToken can typingState.Dispose(); } - _log.WaitingToDeleteTempMessage(reply.Id, command.DeletionDelay.TotalSeconds); - await Task.Delay(command.DeletionDelay, cancellationToken); - await DeleteTempReplyAsync(reply, command, cancellationToken); + _scheduler.Schedule( + new DeleteTempReply + { + Reply = reply, + SourceMessageId = command.SourceMessage.Id, + ReactionInfo = command.ReactionInfo + }, + command.DeletionDelay); - return Unit.Value; - } + _log.DeleteTempMessageScheduled(reply.Id, command.DeletionDelay.TotalSeconds); - /// - /// Deletes a temp reply. If there is a reaction associated with the source message, it will be cleared, too. - /// - /// The reply to delete. - /// The command. - /// The cancellation token. - private async Task DeleteTempReplyAsync(IMessage reply, SendTempReply command, CancellationToken cancellationToken) - { - try - { - // If there is also a reaction and the source message still exists, remove the reaction from it. - if (command.ReactionInfo is not null) - { - var sourceMessage = await reply.Channel.GetMessageAsync( - command.SourceMessage.Id, - options: new RequestOptions { CancelToken = cancellationToken }); - - if (sourceMessage is not null) - { - await sourceMessage.RemoveReactionAsync( - command.ReactionInfo.Emote, - command.ReactionInfo.UserId, - new RequestOptions { CancelToken = cancellationToken }); - } - } - - // Delete the reply message. - try - { - await reply.DeleteAsync(new RequestOptions { CancelToken = cancellationToken }); - _log.DeletedTempMessage(reply.Id); - } - catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.UnknownMessage) - { - // The message was already deleted. - } - } - catch (Exception ex) - { - _log.FailedToDeleteTempMessage(ex, reply.Id); - throw; - } + return Unit.Value; } private sealed partial class Log @@ -146,18 +111,12 @@ public Log(ILogger logger) [LoggerMessage( Level = LogLevel.Error, - Message = "Failed to send temp message for reaction to message ID {referencedMessageId} with text: {text}")] - public partial void FailedToSendTempMessage(Exception ex, ulong referencedMessageId, string text); + Message = "Failed to send temp message for reaction to message ID {sourceMessageId} with text: {text}")] + public partial void FailedToSendTempMessage(Exception ex, ulong sourceMessageId, string text); [LoggerMessage( Level = LogLevel.Information, Message = "Temp message ID {replyId} will be deleted in {totalSeconds}s.")] - public partial void WaitingToDeleteTempMessage(ulong replyId, double totalSeconds); - - [LoggerMessage(Level = LogLevel.Information, Message = "Deleted temp message ID {replyId}.")] - public partial void DeletedTempMessage(ulong replyId); - - [LoggerMessage(Level = LogLevel.Error, Message = "Failed to delete temp message ID {replyId}.")] - public partial void FailedToDeleteTempMessage(Exception ex, ulong replyId); + public partial void DeleteTempMessageScheduled(ulong replyId, double totalSeconds); } } diff --git a/src/DiscordTranslationBot/Jobs/JobExtensions.cs b/src/DiscordTranslationBot/Jobs/JobExtensions.cs new file mode 100644 index 0000000..0d4e3e9 --- /dev/null +++ b/src/DiscordTranslationBot/Jobs/JobExtensions.cs @@ -0,0 +1,9 @@ +namespace DiscordTranslationBot.Jobs; + +internal static class JobExtensions +{ + public static IServiceCollection AddJobs(this IServiceCollection services) + { + return services.AddSingleton().AddHostedService(); + } +} diff --git a/src/DiscordTranslationBot/Jobs/Scheduler.cs b/src/DiscordTranslationBot/Jobs/Scheduler.cs new file mode 100644 index 0000000..90f4960 --- /dev/null +++ b/src/DiscordTranslationBot/Jobs/Scheduler.cs @@ -0,0 +1,101 @@ +using System.Diagnostics.CodeAnalysis; + +namespace DiscordTranslationBot.Jobs; + +public sealed partial class Scheduler : IScheduler +{ + private readonly Log _log; + private readonly IMediator _mediator; + private readonly PriorityQueue, DateTimeOffset> _queue = new(); + private readonly TimeProvider _timeProvider; + + public Scheduler(IMediator mediator, TimeProvider timeProvider, ILogger logger) + { + _mediator = mediator; + _timeProvider = timeProvider; + _log = new Log(logger); + } + + public int Count => _queue.Count; + + public void Schedule(ICommand command, DateTimeOffset executeAt) + { + if (executeAt <= _timeProvider.GetUtcNow()) + { + throw new InvalidOperationException("Tasks can only be scheduled to execute in the future."); + } + + _queue.Enqueue(async ct => await _mediator.Send(command, ct), executeAt); + _log.ScheduledCommand(command.GetType().Name, executeAt.ToLocalTime(), _queue.Count); + } + + public void Schedule(ICommand command, TimeSpan executionDelay) + { + Schedule(command, _timeProvider.GetUtcNow() + executionDelay); + } + + public bool TryGetNextTask([NotNullWhen(true)] out Func? task) + { + if (_queue.TryPeek(out _, out var executeAt) && executeAt <= _timeProvider.GetUtcNow()) + { + task = _queue.Dequeue(); + _log.DequeuedTask(executeAt.ToLocalTime(), _queue.Count); + return true; + } + + task = null; + return false; + } + + private sealed partial class Log + { + private readonly ILogger _logger; + + public Log(ILogger logger) + { + _logger = logger; + } + + [LoggerMessage( + Level = LogLevel.Information, + Message = + "Scheduled command '{commandName}' to be executed at {executeAt}. Total tasks in queue: {totalTasks}.")] + public partial void ScheduledCommand(string commandName, DateTimeOffset executeAt, int totalTasks); + + [LoggerMessage( + Level = LogLevel.Information, + Message = + "Dequeued a task scheduled to be executed at {executeAt}. Remaining tasks in queue: {remainingTasks}.")] + public partial void DequeuedTask(DateTimeOffset executeAt, int remainingTasks); + } +} + +public interface IScheduler +{ + /// + /// The count tasks in the queue. + /// + public int Count { get; } + + /// + /// Queues a Mediator command to run at a specific time. + /// + /// Mediator command to schedule. + /// Time to execute the task at. + public void Schedule(ICommand command, DateTimeOffset executeAt); + + /// + /// Queues a Mediator command to run at a specific time. + /// + /// Mediator command to schedule. + /// Delay for executing the task from now. + public void Schedule(ICommand command, TimeSpan executionDelay); + + /// + /// Try to get the next scheduled task to be executed. + /// If a task exists, it is dequeued. + /// + /// Scheduled task. + /// Scheduled task to be executed or null. + public bool TryGetNextTask([NotNullWhen(true)] out Func? task); +} diff --git a/src/DiscordTranslationBot/Jobs/SchedulerBackgroundService.cs b/src/DiscordTranslationBot/Jobs/SchedulerBackgroundService.cs new file mode 100644 index 0000000..d46baa6 --- /dev/null +++ b/src/DiscordTranslationBot/Jobs/SchedulerBackgroundService.cs @@ -0,0 +1,65 @@ +namespace DiscordTranslationBot.Jobs; + +public sealed partial class SchedulerBackgroundService : BackgroundService +{ + private readonly Log _log; + private readonly IScheduler _scheduler; + + public SchedulerBackgroundService(IScheduler scheduler, ILogger logger) + { + _scheduler = scheduler; + _log = new Log(logger); + } + + public override Task StartAsync(CancellationToken cancellationToken) + { + _log.Starting(); + return base.StartAsync(cancellationToken); + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + _log.Stopping(_scheduler.Count); + return base.StopAsync(cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (_scheduler.TryGetNextTask(out var task)) + { + _log.TaskExecuting(); + await task(stoppingToken); + _log.TaskExecuted(); + } + + // Wait some time before checking the queue again to reduce overloading CPU resources. + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + } + } + + private sealed partial class Log + { + private readonly ILogger _logger; + + public Log(ILogger logger) + { + _logger = logger; + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Starting scheduler background service...")] + public partial void Starting(); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Stopping scheduler background service with {remainingTasks} remaining tasks in the queue...")] + public partial void Stopping(int remainingTasks); + + [LoggerMessage(Level = LogLevel.Information, Message = "Executing scheduled task...")] + public partial void TaskExecuting(); + + [LoggerMessage(Level = LogLevel.Information, Message = "Executed scheduled task.")] + public partial void TaskExecuted(); + } +} diff --git a/src/DiscordTranslationBot/Program.cs b/src/DiscordTranslationBot/Program.cs index 3605650..499db5a 100644 --- a/src/DiscordTranslationBot/Program.cs +++ b/src/DiscordTranslationBot/Program.cs @@ -3,6 +3,7 @@ using DiscordTranslationBot; using DiscordTranslationBot.Discord; using DiscordTranslationBot.Extensions; +using DiscordTranslationBot.Jobs; using DiscordTranslationBot.Mediator; using DiscordTranslationBot.Providers.Translation; using DiscordTranslationBot.Services; @@ -43,7 +44,8 @@ })) .AddSingleton() .AddSingleton() - .AddHostedService(); + .AddHostedService() + .AddJobs(); // Mediator. builder diff --git a/tests/DiscordTranslationBot.Tests.Unit/.editorconfig b/tests/DiscordTranslationBot.Tests.Unit/.editorconfig index 3bf4cb5..e9385ed 100644 --- a/tests/DiscordTranslationBot.Tests.Unit/.editorconfig +++ b/tests/DiscordTranslationBot.Tests.Unit/.editorconfig @@ -11,3 +11,9 @@ dotnet_diagnostic.VSTHRD200.severity = none # AsyncFixer01: Unnecessary async/await usage dotnet_diagnostic.AsyncFixer01.severity = none + +# CA1711: Identifiers should not have incorrect suffix +dotnet_diagnostic.CA1711.severity = none + +# CA2201: Do not raise reserved exception types +dotnet_diagnostic.CA2201.severity = none diff --git a/tests/DiscordTranslationBot.Tests.Unit/Commands/TempReplies/DeleteTempReplyHandlerTests.cs b/tests/DiscordTranslationBot.Tests.Unit/Commands/TempReplies/DeleteTempReplyHandlerTests.cs new file mode 100644 index 0000000..a01eb41 --- /dev/null +++ b/tests/DiscordTranslationBot.Tests.Unit/Commands/TempReplies/DeleteTempReplyHandlerTests.cs @@ -0,0 +1,187 @@ +using System.Net; +using Discord; +using Discord.Net; +using DiscordTranslationBot.Commands.TempReplies; +using DiscordTranslationBot.Discord.Models; + +namespace DiscordTranslationBot.Tests.Unit.Commands.TempReplies; + +public sealed class DeleteTempReplyHandlerTests +{ + private readonly LoggerFake _logger; + private readonly DeleteTempReplyHandler _sut; + + public DeleteTempReplyHandlerTests() + { + _logger = new LoggerFake(); + + _sut = new DeleteTempReplyHandler(_logger); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Handle_DeleteTempReply_Success(bool hasReactionInfo) + { + // Arrange + var command = new DeleteTempReply + { + Reply = Substitute.For(), + SourceMessageId = 1UL, + ReactionInfo = hasReactionInfo + ? new ReactionInfo + { + UserId = 1, + Emote = Substitute.For() + } + : null + }; + + const ulong replyId = 5UL; + command.Reply.Id.Returns(replyId); + + var sourceMessage = Substitute.For(); + if (hasReactionInfo) + { + command + .Reply + .Channel + .GetMessageAsync(command.SourceMessageId, options: Arg.Any()) + .Returns(sourceMessage); + } + + // Act + await _sut.Handle(command, CancellationToken.None); + + // Assert + await command + .Reply + .Channel + .Received(hasReactionInfo ? 1 : 0) + .GetMessageAsync(command.SourceMessageId, options: Arg.Any()); + + await sourceMessage + .Received(hasReactionInfo ? 1 : 0) + .RemoveReactionAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + await command.Reply.ReceivedWithAnyArgs(1).DeleteAsync(); + + _logger + .Entries + .Should() + .ContainSingle( + x => x.LogLevel == LogLevel.Information && x.Message == $"Deleted temp message ID {replyId}."); + } + + [Fact] + public async Task Handle_DeleteTempReply_NoSourceMessageFound_Success() + { + // Arrange + var command = new DeleteTempReply + { + Reply = Substitute.For(), + SourceMessageId = 1UL, + ReactionInfo = new ReactionInfo + { + UserId = 1, + Emote = Substitute.For() + } + }; + + const ulong replyId = 5UL; + command.Reply.Id.Returns(replyId); + + command + .Reply + .Channel + .GetMessageAsync(command.SourceMessageId, options: Arg.Any()) + .Returns((IUserMessage?)null); + + // Act + await _sut.Handle(command, CancellationToken.None); + + // Assert + await command + .Reply + .Channel + .Received(1) + .GetMessageAsync(command.SourceMessageId, options: Arg.Any()); + + await command.Reply.ReceivedWithAnyArgs(1).DeleteAsync(); + + _logger + .Entries + .Should() + .ContainSingle( + x => x.LogLevel == LogLevel.Information && x.Message == $"Deleted temp message ID {replyId}."); + } + + [Fact] + public async Task Handle_DeleteTempReply_TempMessageNotFound() + { + // Arrange + var command = new DeleteTempReply + { + Reply = Substitute.For(), + SourceMessageId = 1UL, + ReactionInfo = null + }; + + const ulong replyId = 5UL; + command.Reply.Id.Returns(replyId); + + command + .Reply + .DeleteAsync() + .ThrowsAsyncForAnyArgs( + new HttpException( + HttpStatusCode.NotFound, + Substitute.For(), + DiscordErrorCode.UnknownMessage)); + + // Act + await _sut.Handle(command, CancellationToken.None); + + // Assert + await command.Reply.ReceivedWithAnyArgs(1).DeleteAsync(); + + _logger + .Entries + .Should() + .ContainSingle( + x => x.LogLevel == LogLevel.Information + && x.Message == $"Temp message ID {replyId} was not found and likely manually deleted."); + } + + [Fact] + public async Task Handle_DeleteTempReply_FailedToDeleteTempMessage() + { + // Arrange + var command = new DeleteTempReply + { + Reply = Substitute.For(), + SourceMessageId = 1UL, + ReactionInfo = null + }; + + const ulong replyId = 5UL; + command.Reply.Id.Returns(replyId); + + var exception = new Exception(); + command.Reply.DeleteAsync().ThrowsAsyncForAnyArgs(exception); + + // Act + Assert + await _sut.Awaiting(x => x.Handle(command, CancellationToken.None)).Should().ThrowAsync(); + + // Assert + await command.Reply.ReceivedWithAnyArgs(1).DeleteAsync(); + + _logger + .Entries + .Should() + .ContainSingle( + x => x.LogLevel == LogLevel.Error + && ReferenceEquals(x.Exception, exception) + && x.Message == $"Failed to delete temp message ID {replyId}."); + } +} diff --git a/tests/DiscordTranslationBot.Tests.Unit/Commands/TempReplies/DeleteTempReplyTests.cs b/tests/DiscordTranslationBot.Tests.Unit/Commands/TempReplies/DeleteTempReplyTests.cs new file mode 100644 index 0000000..fe27b8d --- /dev/null +++ b/tests/DiscordTranslationBot.Tests.Unit/Commands/TempReplies/DeleteTempReplyTests.cs @@ -0,0 +1,59 @@ +using Discord; +using DiscordTranslationBot.Commands.TempReplies; +using DiscordTranslationBot.Discord.Models; +using DiscordTranslationBot.Extensions; + +namespace DiscordTranslationBot.Tests.Unit.Commands.TempReplies; + +public sealed class DeleteTempReplyTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Valid_ValidatesWithoutErrors(bool hasReactionInfo) + { + // Arrange + var command = new DeleteTempReply + { + Reply = Substitute.For(), + SourceMessageId = 1UL, + ReactionInfo = hasReactionInfo + ? new ReactionInfo + { + UserId = 1, + Emote = Substitute.For() + } + : null + }; + + // Act + var isValid = command.TryValidate(out var validationResults); + + // Assert + isValid.Should().BeTrue(); + validationResults.Should().BeEmpty(); + } + + [Fact] + public void Invalid_Reply_HasValidationError() + { + // Arrange + var command = new DeleteTempReply + { + Reply = null!, + SourceMessageId = 1UL, + ReactionInfo = new ReactionInfo + { + UserId = 1, + Emote = Substitute.For() + } + }; + + // Act + var isValid = command.TryValidate(out var validationResults); + + // Assert + isValid.Should().BeFalse(); + validationResults.Should().OnlyContain(x => x.MemberNames.All(y => y == nameof(command.Reply))); + } +} diff --git a/tests/DiscordTranslationBot.Tests.Unit/Commands/TempReplies/SendTempReplyHandlerTests.cs b/tests/DiscordTranslationBot.Tests.Unit/Commands/TempReplies/SendTempReplyHandlerTests.cs index f1f693c..b73e458 100644 --- a/tests/DiscordTranslationBot.Tests.Unit/Commands/TempReplies/SendTempReplyHandlerTests.cs +++ b/tests/DiscordTranslationBot.Tests.Unit/Commands/TempReplies/SendTempReplyHandlerTests.cs @@ -1,14 +1,23 @@ -using System.Net; -using Discord; -using Discord.Net; +using Discord; using DiscordTranslationBot.Commands.TempReplies; using DiscordTranslationBot.Discord.Models; +using DiscordTranslationBot.Jobs; namespace DiscordTranslationBot.Tests.Unit.Commands.TempReplies; public sealed class SendTempReplyHandlerTests { - private readonly SendTempReplyHandler _sut = new(new LoggerFake()); + private readonly LoggerFake _logger; + private readonly IScheduler _scheduler; + private readonly SendTempReplyHandler _sut; + + public SendTempReplyHandlerTests() + { + _scheduler = Substitute.For(); + _logger = new LoggerFake(); + + _sut = new SendTempReplyHandler(_scheduler, _logger); + } [Theory] [InlineData(true)] @@ -26,78 +35,63 @@ public async Task Handle_SendTempReply_Success(bool hasReaction) Emote = Substitute.For() } : null, - SourceMessage = Substitute.For(), - DeletionDelay = TimeSpan.FromTicks(1) + SourceMessage = Substitute.For() }; + const ulong sourceMessageId = 100UL; + command.SourceMessage.Id.Returns(sourceMessageId); + var reply = Substitute.For(); command.SourceMessage.Channel.SendMessageAsync().ReturnsForAnyArgs(reply); - if (hasReaction) - { - reply - .Channel - .GetMessageAsync( - Arg.Is(x => x == command.SourceMessage.Id), - Arg.Any(), - Arg.Any()) - .Returns(command.SourceMessage); - } - // Act await _sut.Handle(command, CancellationToken.None); // Assert command.SourceMessage.Channel.ReceivedWithAnyArgs(1).EnterTypingState(); - await command.SourceMessage.Channel.ReceivedWithAnyArgs(1).SendMessageAsync(); - await reply - .Channel - .Received(hasReaction ? 1 : 0) - .GetMessageAsync( - Arg.Is(x => x == command.SourceMessage.Id), - Arg.Any(), - Arg.Any()); - - await command - .SourceMessage - .Received(hasReaction ? 1 : 0) - .RemoveReactionAsync(Arg.Any(), Arg.Any(), Arg.Any()); - - await reply.ReceivedWithAnyArgs(1).DeleteAsync(); + _scheduler + .Received(1) + .Schedule( + Arg.Is( + x => ReferenceEquals(x.Reply, reply) + && x.SourceMessageId == sourceMessageId + && ReferenceEquals(x.ReactionInfo, command.ReactionInfo)), + command.DeletionDelay); } [Fact] - public async Task Handle_SendTempReply_Success_TempReplyAlreadyDeleted() + public async Task Handle_SendTempReply_FailedToSendTempMessage() { // Arrange var command = new SendTempReply { Text = "test", ReactionInfo = null, - SourceMessage = Substitute.For(), - DeletionDelay = TimeSpan.FromTicks(1) + SourceMessage = Substitute.For() }; - var reply = Substitute.For(); - command.SourceMessage.Channel.SendMessageAsync().ReturnsForAnyArgs(reply); + const ulong sourceMessageId = 100UL; + command.SourceMessage.Id.Returns(sourceMessageId); - reply - .DeleteAsync(Arg.Any()) - .ThrowsAsync( - new HttpException( - HttpStatusCode.NotFound, - Substitute.For(), - DiscordErrorCode.UnknownMessage)); + var exception = new Exception(); + command.SourceMessage.Channel.SendMessageAsync().ThrowsAsyncForAnyArgs(exception); - // Act & Assert - await _sut.Awaiting(x => x.Handle(command, CancellationToken.None)).Should().NotThrowAsync(); + // Act + Assert + await _sut.Awaiting(x => x.Handle(command, CancellationToken.None)).Should().ThrowAsync(); command.SourceMessage.Channel.ReceivedWithAnyArgs(1).EnterTypingState(); - await command.SourceMessage.Channel.ReceivedWithAnyArgs(1).SendMessageAsync(); - await reply.ReceivedWithAnyArgs(1).DeleteAsync(); + _logger + .Entries + .Should() + .ContainSingle( + x => x.LogLevel == LogLevel.Error + && ReferenceEquals(x.Exception, exception) + && x.Message.Contains($"message ID {sourceMessageId}")); + + _scheduler.ReceivedCalls().Should().BeEmpty(); } } diff --git a/tests/DiscordTranslationBot.Tests.Unit/Jobs/SchedulerTests.cs b/tests/DiscordTranslationBot.Tests.Unit/Jobs/SchedulerTests.cs new file mode 100644 index 0000000..f4b7deb --- /dev/null +++ b/tests/DiscordTranslationBot.Tests.Unit/Jobs/SchedulerTests.cs @@ -0,0 +1,122 @@ +using DiscordTranslationBot.Jobs; +using Mediator; + +namespace DiscordTranslationBot.Tests.Unit.Jobs; + +public sealed class SchedulerTests +{ + private readonly IMediator _mediator; + private readonly Scheduler _sut; + private readonly TimeProvider _timeProvider; + + public SchedulerTests() + { + _mediator = Substitute.For(); + _timeProvider = Substitute.For(); + + _sut = new Scheduler(_mediator, _timeProvider, new LoggerFake()); + } + + [Fact] + public void Schedule_CommandWithExecuteAt_Success() + { + // Arrange + _timeProvider.GetUtcNow().Returns(DateTimeOffset.MinValue); + + var command = Substitute.For(); + var executeAt = DateTimeOffset.MinValue.AddDays(2); + + // Act + _sut.Schedule(command, executeAt); + + // Assert + _sut.Count.Should().Be(1); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Schedule_CommandWithExecuteAt_ThrowsIfInvalidTime(int seconds) + { + // Arrange + _timeProvider.GetUtcNow().Returns(DateTimeOffset.MaxValue); + + var command = Substitute.For(); + var executeAt = DateTimeOffset.MaxValue.AddSeconds(seconds); + + // Act + Assert + _sut.Invoking(x => x.Schedule(command, executeAt)).Should().Throw(); + } + + [Fact] + public void Schedule_CommandWithExecutionDelay_Success() + { + // Arrange + _timeProvider.GetUtcNow().Returns(DateTimeOffset.MinValue); + + var command = Substitute.For(); + var executionDelay = TimeSpan.FromHours(2); + + // Act + _sut.Schedule(command, executionDelay); + + // Assert + _sut.Count.Should().Be(1); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Schedule_CommandWithExecutionDelay_ThrowsIfInvalidTime(int seconds) + { + // Arrange + _timeProvider.GetUtcNow().Returns(DateTimeOffset.MaxValue); + + var command = Substitute.For(); + var executeAt = TimeSpan.FromSeconds(seconds); + + // Act + Assert + _sut.Invoking(x => x.Schedule(command, executeAt)).Should().Throw(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TryGetNextTask_Success(bool hasTaskScheduled) + { + // Arrange + if (hasTaskScheduled) + { + // Allow command to be scheduled. + _timeProvider.GetUtcNow().Returns(DateTimeOffset.MinValue); + + var command = Substitute.For(); + var executeAt = DateTimeOffset.MinValue.AddDays(2); + _sut.Schedule(command, executeAt); + } + + // Modify time so task is returned. + _timeProvider.GetUtcNow().Returns(DateTimeOffset.MaxValue); + + var countBeforeGet = _sut.Count; + + // Act + var result = _sut.TryGetNextTask(out var task); + + // Assert + if (hasTaskScheduled) + { + result.Should().BeTrue(); + task.Should().NotBeNull(); + countBeforeGet.Should().Be(1); + } + else + { + result.Should().BeFalse(); + task.Should().BeNull(); + countBeforeGet.Should().Be(0); + } + + _sut.Count.Should().Be(0); + } +}