From 7d9a0ae3d9ace3d111af70ba41b514730bc714ae Mon Sep 17 00:00:00 2001 From: Mark Ridgwell <273118822+dnyw4l3n13@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:36:36 +0000 Subject: [PATCH 1/4] feat(discord): Add BuildBot.Discord.Tests with 100% coverage Introduces IDiscordRawClient and IDiscordChannel public interfaces with internal DiscordSocketClientAdapter/DiscordChannelAdapter sealed adapters so DiscordBot and BotService are fully testable without Discord.Net sealed types. Adds BuildBot.Discord.Tests covering all public types. Closes #357 Co-Authored-By: Claude Sonnet 4.6 --- .markdownlintignore | 1 + CHANGELOG.md | 1 + .../BuildBot.Discord.Tests.csproj | 78 ++++ .../DependencyInjectionTests.cs | 57 +++ .../Models/BotMessageTests.cs | 37 ++ .../Models/BotReleaseMessageTests.cs | 37 ++ ...scordBotMessageNotificationHandlerTests.cs | 36 ++ ...tReleaseMessageNotificationHandlerTests.cs | 30 ++ .../Services/BotServiceTests.cs | 108 +++++ .../Services/DiscordBotConfigurationTests.cs | 60 +++ .../Services/DiscordBotTests.cs | 381 ++++++++++++++++++ .../Services/MessageChannelTests.cs | 33 ++ src/BuildBot.Discord/DiscordSetup.cs | 1 + src/BuildBot.Discord/IDiscordBot.cs | 4 + src/BuildBot.Discord/Services/BotService.cs | 8 +- src/BuildBot.Discord/Services/DiscordBot.cs | 78 ++-- .../Services/DiscordChannelAdapter.cs | 31 ++ .../Services/DiscordSocketClientAdapter.cs | 68 ++++ .../Services/IDiscordChannel.cs | 14 + .../Services/IDiscordRawClient.cs | 24 ++ src/BuildBot.slnx | 1 + src/UnitTests.props | 17 + 22 files changed, 1046 insertions(+), 59 deletions(-) create mode 100644 .markdownlintignore create mode 100644 src/BuildBot.Discord.Tests/BuildBot.Discord.Tests.csproj create mode 100644 src/BuildBot.Discord.Tests/DependencyInjectionTests.cs create mode 100644 src/BuildBot.Discord.Tests/Models/BotMessageTests.cs create mode 100644 src/BuildBot.Discord.Tests/Models/BotReleaseMessageTests.cs create mode 100644 src/BuildBot.Discord.Tests/Publishers/DiscordBotMessageNotificationHandlerTests.cs create mode 100644 src/BuildBot.Discord.Tests/Publishers/DiscordBotReleaseMessageNotificationHandlerTests.cs create mode 100644 src/BuildBot.Discord.Tests/Services/BotServiceTests.cs create mode 100644 src/BuildBot.Discord.Tests/Services/DiscordBotConfigurationTests.cs create mode 100644 src/BuildBot.Discord.Tests/Services/DiscordBotTests.cs create mode 100644 src/BuildBot.Discord.Tests/Services/MessageChannelTests.cs create mode 100644 src/BuildBot.Discord/Services/DiscordChannelAdapter.cs create mode 100644 src/BuildBot.Discord/Services/DiscordSocketClientAdapter.cs create mode 100644 src/BuildBot.Discord/Services/IDiscordChannel.cs create mode 100644 src/BuildBot.Discord/Services/IDiscordRawClient.cs diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 000000000..1b763b1ba --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1 @@ +CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 820f0a1a0..bbd8c55b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Please ADD ALL Changes to the UNRELEASED SECTION and not a specific release - BuildBot.Health.Tests: new test project covering BuildBot.Health at 100% line and branch coverage - BuildBot.Json.Tests: new test project covering BuildBot.Json at 100% line and branch coverage - BuildBot.ServiceModel.Tests: Added test coverage for all types in BuildBot.ServiceModel +- BuildBot.Discord.Tests: Added unit tests to increase code coverage to 100% ### Fixed - Suppress known Scriban 6.2.0 vulnerabilities pending upgrade - SnsMessage: Token property was never populated from the constructor argument diff --git a/src/BuildBot.Discord.Tests/BuildBot.Discord.Tests.csproj b/src/BuildBot.Discord.Tests/BuildBot.Discord.Tests.csproj new file mode 100644 index 000000000..5f19f1d1b --- /dev/null +++ b/src/BuildBot.Discord.Tests/BuildBot.Discord.Tests.csproj @@ -0,0 +1,78 @@ + + + latest + AllEnabledByDefault + true + true + true + true + true + false + true + false + strict;flow-analysis + true + false + Size + disable + false + false + false + false + latest + + true + high + all + enable + speed + Exe + false + net10.0 + true + true + true + + True + true + true + false + + + + + + <_CoverageExcludedFunctions Include=".*DiscordSocketClientAdapter.*" /> + <_CoverageExcludedFunctions Include=".*DiscordChannelAdapter.*" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/BuildBot.Discord.Tests/DependencyInjectionTests.cs b/src/BuildBot.Discord.Tests/DependencyInjectionTests.cs new file mode 100644 index 000000000..2d0ae632c --- /dev/null +++ b/src/BuildBot.Discord.Tests/DependencyInjectionTests.cs @@ -0,0 +1,57 @@ +using BuildBot.Discord; +using BuildBot.Discord.Models; +using BuildBot.ServiceModel.ComponentStatus; +using FunFair.Test.Common; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace BuildBot.Discord.Tests; + +public sealed class DependencyInjectionTests : DependencyInjectionTestsBase +{ + private static readonly DiscordBotConfiguration TestConfig = new( + token: "test-token", + server: "test-server", + channel: "test-channel", + releaseChannel: "test-release-channel" + ); + + public DependencyInjectionTests(ITestOutputHelper output) + : base(output: output, dependencyInjectionRegistration: Configure) { } + + private static IServiceCollection Configure(IServiceCollection services) + { + return services.AddDiscord(TestConfig); + } + + [Fact] + public void DiscordBotMustBeRegisteredAsIDiscordBot() + { + this.RequireService(); + } + + [Fact] + public void DiscordBotMustBeRegisteredAsIComponentStatus() + { + this.RequireService(); + } + + [Fact] + public void BotMessageChannelMustBeRegistered() + { + this.RequireService>(); + } + + [Fact] + public void BotReleaseMessageChannelMustBeRegistered() + { + this.RequireService>(); + } + + [Fact] + public void BotServiceMustBeRegisteredAsIHostedService() + { + this.RequireService(); + } +} diff --git a/src/BuildBot.Discord.Tests/Models/BotMessageTests.cs b/src/BuildBot.Discord.Tests/Models/BotMessageTests.cs new file mode 100644 index 000000000..1288c2ced --- /dev/null +++ b/src/BuildBot.Discord.Tests/Models/BotMessageTests.cs @@ -0,0 +1,37 @@ +using BuildBot.Discord.Models; +using Discord; +using FunFair.Test.Common; +using Xunit; + +namespace BuildBot.Discord.Tests.Models; + +public sealed class BotMessageTests : TestBase +{ + [Fact] + public void MessageProperty_ReturnsSuppliedBuilder() + { + EmbedBuilder builder = new(); + BotMessage msg = new(builder); + + Assert.Same(expected: builder, actual: msg.Message); + } + + [Fact] + public void TwoInstances_WithSameBuilder_AreEqual() + { + EmbedBuilder builder = new(); + BotMessage msg1 = new(builder); + BotMessage msg2 = new(builder); + + Assert.Equal(expected: msg1, actual: msg2); + } + + [Fact] + public void TwoInstances_WithDifferentBuilders_AreNotEqual() + { + BotMessage msg1 = new(new EmbedBuilder().WithTitle("First Message")); + BotMessage msg2 = new(new EmbedBuilder().WithTitle("Second Message")); + + Assert.NotEqual(expected: msg1, actual: msg2); + } +} diff --git a/src/BuildBot.Discord.Tests/Models/BotReleaseMessageTests.cs b/src/BuildBot.Discord.Tests/Models/BotReleaseMessageTests.cs new file mode 100644 index 000000000..152d977e0 --- /dev/null +++ b/src/BuildBot.Discord.Tests/Models/BotReleaseMessageTests.cs @@ -0,0 +1,37 @@ +using BuildBot.Discord.Models; +using Discord; +using FunFair.Test.Common; +using Xunit; + +namespace BuildBot.Discord.Tests.Models; + +public sealed class BotReleaseMessageTests : TestBase +{ + [Fact] + public void MessageProperty_ReturnsSuppliedBuilder() + { + EmbedBuilder builder = new(); + BotReleaseMessage msg = new(builder); + + Assert.Same(expected: builder, actual: msg.Message); + } + + [Fact] + public void TwoInstances_WithSameBuilder_AreEqual() + { + EmbedBuilder builder = new(); + BotReleaseMessage msg1 = new(builder); + BotReleaseMessage msg2 = new(builder); + + Assert.Equal(expected: msg1, actual: msg2); + } + + [Fact] + public void TwoInstances_WithDifferentBuilders_AreNotEqual() + { + BotReleaseMessage msg1 = new(new EmbedBuilder().WithTitle("First Release")); + BotReleaseMessage msg2 = new(new EmbedBuilder().WithTitle("Second Release")); + + Assert.NotEqual(expected: msg1, actual: msg2); + } +} diff --git a/src/BuildBot.Discord.Tests/Publishers/DiscordBotMessageNotificationHandlerTests.cs b/src/BuildBot.Discord.Tests/Publishers/DiscordBotMessageNotificationHandlerTests.cs new file mode 100644 index 000000000..f09458664 --- /dev/null +++ b/src/BuildBot.Discord.Tests/Publishers/DiscordBotMessageNotificationHandlerTests.cs @@ -0,0 +1,36 @@ +using System.Threading; +using System.Threading.Tasks; +using BuildBot.Discord.Models; +using BuildBot.Discord.Publishers; +using Discord; +using FunFair.Test.Common; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace BuildBot.Discord.Tests.Publishers; + +public sealed class DiscordBotMessageNotificationHandlerTests : TestBase +{ + [Fact] + public async Task Handle_LogsAndPublishesToChannel() + { + ILogger logger = + this.GetTypedLogger(); + logger.IsEnabled(Arg.Any()).Returns(true); + + IMessageChannel channel = GetSubstitute>(); + + DiscordBotMessageNotificationHandler handler = new(messageChannel: channel, logger: logger); + + EmbedBuilder builder = new EmbedBuilder().WithTitle("Test Notification"); + BotMessage notification = new(builder); + + await handler.Handle(notification: notification, cancellationToken: this.CancellationToken()); + + logger.Received(1).IsEnabled(LogLevel.Information); + await channel + .Received(1) + .PublishAsync(message: Arg.Any(), cancellationToken: Arg.Any()); + } +} diff --git a/src/BuildBot.Discord.Tests/Publishers/DiscordBotReleaseMessageNotificationHandlerTests.cs b/src/BuildBot.Discord.Tests/Publishers/DiscordBotReleaseMessageNotificationHandlerTests.cs new file mode 100644 index 000000000..c938fef64 --- /dev/null +++ b/src/BuildBot.Discord.Tests/Publishers/DiscordBotReleaseMessageNotificationHandlerTests.cs @@ -0,0 +1,30 @@ +using System.Threading; +using System.Threading.Tasks; +using BuildBot.Discord.Models; +using BuildBot.Discord.Publishers; +using Discord; +using FunFair.Test.Common; +using NSubstitute; +using Xunit; + +namespace BuildBot.Discord.Tests.Publishers; + +public sealed class DiscordBotReleaseMessageNotificationHandlerTests : TestBase +{ + [Fact] + public async Task Handle_PublishesToChannel() + { + IMessageChannel channel = GetSubstitute>(); + + DiscordBotReleaseMessageNotificationHandler handler = new(channel); + + EmbedBuilder builder = new EmbedBuilder().WithTitle("Release Notification"); + BotReleaseMessage notification = new(builder); + + await handler.Handle(notification: notification, cancellationToken: this.CancellationToken()); + + await channel + .Received(1) + .PublishAsync(message: Arg.Any(), cancellationToken: Arg.Any()); + } +} diff --git a/src/BuildBot.Discord.Tests/Services/BotServiceTests.cs b/src/BuildBot.Discord.Tests/Services/BotServiceTests.cs new file mode 100644 index 000000000..426952ccc --- /dev/null +++ b/src/BuildBot.Discord.Tests/Services/BotServiceTests.cs @@ -0,0 +1,108 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using BuildBot.Discord.Models; +using BuildBot.Discord.Services; +using Discord; +using FunFair.Test.Common; +using NSubstitute; +using Xunit; + +namespace BuildBot.Discord.Tests.Services; + +public sealed class BotServiceTests : TestBase +{ + private static ( + BotService Service, + IDiscordBot Bot, + MessageChannel MessageChannel, + MessageChannel ReleaseChannel + ) CreateService() + { + IDiscordBot bot = GetSubstitute(); + bot.StartAsync(Arg.Any()).Returns(Task.CompletedTask); + bot.StopAsync(Arg.Any()).Returns(Task.CompletedTask); + + MessageChannel messageChannel = new(); + MessageChannel releaseChannel = new(); + + BotService service = new(bot: bot, botMessageChannel: messageChannel, botReleaseMessageChannel: releaseChannel); + + return (service, bot, messageChannel, releaseChannel); + } + + [Fact] + public async Task StartAsync_DelegatesToBot() + { + (BotService service, IDiscordBot bot, _, _) = CreateService(); + + using (service) + { + await service.StartAsync(this.CancellationToken()); + + await bot.Received(1).StartAsync(Arg.Any()); + } + } + + [Fact] + public async Task StopAsync_DelegatesToBot() + { + (BotService service, IDiscordBot bot, _, _) = CreateService(); + + using (service) + { + await service.StopAsync(this.CancellationToken()); + + await bot.Received(1).StopAsync(Arg.Any()); + } + } + + [Fact] + public void Dispose_DisposesSubscriptions() + { + (BotService service, _, _, _) = CreateService(); + + service.Dispose(); + } + + [Fact] + public async Task BotMessage_IsForwardedToBot_AfterDelay() + { + (BotService service, IDiscordBot bot, MessageChannel messageChannel, _) = CreateService(); + + using (service) + { + EmbedBuilder builder = new EmbedBuilder().WithTitle("Test"); + BotMessage message = new(builder); + + await messageChannel.PublishAsync(message: message, cancellationToken: this.CancellationToken()); + + await Task.Delay(TimeSpan.FromSeconds(2), this.CancellationToken()); + + await bot.Received(1) + .PublishAsync(builder: Arg.Any(), cancellationToken: Arg.Any()); + } + } + + [Fact] + public async Task BotReleaseMessage_IsForwardedToBot_AfterDelay() + { + (BotService service, IDiscordBot bot, _, MessageChannel releaseChannel) = CreateService(); + + using (service) + { + EmbedBuilder builder = new EmbedBuilder().WithTitle("Release"); + BotReleaseMessage message = new(builder); + + await releaseChannel.PublishAsync(message: message, cancellationToken: this.CancellationToken()); + + await Task.Delay(TimeSpan.FromSeconds(2), this.CancellationToken()); + + await bot.Received(1) + .PublishToReleaseChannelAsync( + builder: Arg.Any(), + cancellationToken: Arg.Any() + ); + } + } +} diff --git a/src/BuildBot.Discord.Tests/Services/DiscordBotConfigurationTests.cs b/src/BuildBot.Discord.Tests/Services/DiscordBotConfigurationTests.cs new file mode 100644 index 000000000..cd7bfd17b --- /dev/null +++ b/src/BuildBot.Discord.Tests/Services/DiscordBotConfigurationTests.cs @@ -0,0 +1,60 @@ +using BuildBot.Discord; +using FunFair.Test.Common; +using Xunit; + +namespace BuildBot.Discord.Tests.Services; + +public sealed class DiscordBotConfigurationTests : TestBase +{ + [Fact] + public void Token_ReturnsConstructorValue() + { + DiscordBotConfiguration config = new( + token: "my-token", + server: "server", + channel: "channel", + releaseChannel: "releases" + ); + + Assert.Equal(expected: "my-token", actual: config.Token); + } + + [Fact] + public void Server_ReturnsConstructorValue() + { + DiscordBotConfiguration config = new( + token: "token", + server: "my-server", + channel: "channel", + releaseChannel: "releases" + ); + + Assert.Equal(expected: "my-server", actual: config.Server); + } + + [Fact] + public void Channel_ReturnsConstructorValue() + { + DiscordBotConfiguration config = new( + token: "token", + server: "server", + channel: "my-channel", + releaseChannel: "releases" + ); + + Assert.Equal(expected: "my-channel", actual: config.Channel); + } + + [Fact] + public void ReleaseChannel_ReturnsConstructorValue() + { + DiscordBotConfiguration config = new( + token: "token", + server: "server", + channel: "channel", + releaseChannel: "my-releases" + ); + + Assert.Equal(expected: "my-releases", actual: config.ReleaseChannel); + } +} diff --git a/src/BuildBot.Discord.Tests/Services/DiscordBotTests.cs b/src/BuildBot.Discord.Tests/Services/DiscordBotTests.cs new file mode 100644 index 000000000..0eed2538a --- /dev/null +++ b/src/BuildBot.Discord.Tests/Services/DiscordBotTests.cs @@ -0,0 +1,381 @@ +using System; +using System.Threading.Tasks; +using BuildBot.Discord.Services; +using BuildBot.ServiceModel.ComponentStatus; +using Discord; +using FunFair.Test.Common; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace BuildBot.Discord.Tests.Services; + +public sealed class DiscordBotTests : TestBase +{ + private static readonly DiscordBotConfiguration Config = new( + token: "test-token", + server: "test-server", + channel: "test-channel", + releaseChannel: "test-release-channel" + ); + + private (IDiscordRawClient Client, ILogger Logger, DiscordBot Bot) CreateBot() + { + IDiscordRawClient client = GetSubstitute(); + ILogger logger = this.GetTypedLogger(); + logger.IsEnabled(Arg.Any()).Returns(true); + + DiscordBot bot = new(client: client, botConfiguration: Config, logger: logger); + + return (client, logger, bot); + } + + private (ILogger Logger, Func Handler) CreateBotForLogTests() + { + IDiscordRawClient client = GetSubstitute(); + ILogger logger = this.GetTypedLogger(); + logger.IsEnabled(Arg.Any()).Returns(true); + + Func? capturedHandler = null; + client + .When(x => x.RegisterLogHandler(Arg.Any>())) + .Do(callInfo => capturedHandler = callInfo.Arg>()); + + _ = new DiscordBot(client: client, botConfiguration: Config, logger: logger); + + return ( + logger, + capturedHandler + ?? throw new InvalidOperationException("DiscordBot constructor did not call RegisterLogHandler") + ); + } + + [Fact] + public void GetStatus_WhenLoggedOut_ReturnsDisconnectedStatus() + { + (IDiscordRawClient client, _, DiscordBot bot) = this.CreateBot(); + + client.LoginState.Returns(LoginState.LoggedOut); + + ServiceStatus status = bot.GetStatus(); + + Assert.Equal(expected: "Discord", actual: status.Name); + Assert.False(condition: status.Ok, userMessage: "Discord should not be connected when logged out"); + } + + [Fact] + public void GetStatus_WhenLoggedIn_ReturnsConnectedStatus() + { + (IDiscordRawClient client, _, DiscordBot bot) = this.CreateBot(); + + client.LoginState.Returns(LoginState.LoggedIn); + + ServiceStatus status = bot.GetStatus(); + + Assert.Equal(expected: "Discord", actual: status.Name); + Assert.True(condition: status.Ok, userMessage: "Discord should be connected when logged in"); + } + + [Fact] + public async Task PublishAsync_WhenChannelNotFound_LogsErrorAndReturns() + { + (IDiscordRawClient client, ILogger logger, DiscordBot bot) = this.CreateBot(); + + client + .FindChannel(serverName: Arg.Any(), channelName: Arg.Any()) + .Returns((IDiscordChannel?)null); + + await bot.PublishAsync( + builder: new EmbedBuilder().WithTitle("Test"), + cancellationToken: this.CancellationToken() + ); + + client.Received(1).FindChannel(serverName: Config.Server, channelName: Config.Channel); + logger.Received(1).IsEnabled(LogLevel.Error); + } + + [Fact] + public async Task PublishToReleaseChannelAsync_WhenChannelNotFound_LogsErrorAndReturns() + { + (IDiscordRawClient client, ILogger logger, DiscordBot bot) = this.CreateBot(); + + client + .FindChannel(serverName: Arg.Any(), channelName: Arg.Any()) + .Returns((IDiscordChannel?)null); + + await bot.PublishToReleaseChannelAsync( + builder: new EmbedBuilder().WithTitle("Test"), + cancellationToken: this.CancellationToken() + ); + + client.Received(1).FindChannel(serverName: Config.Server, channelName: Config.ReleaseChannel); + logger.Received(1).IsEnabled(LogLevel.Error); + } + + [Fact] + public async Task PublishAsync_WhenChannelFound_SendsMessageAndLogsSuccess() + { + (IDiscordRawClient client, _, DiscordBot bot) = this.CreateBot(); + + IDiscordChannel mockChannel = GetSubstitute(); + IDisposable mockTypingState = GetSubstitute(); + + mockChannel.Name.Returns("test-channel"); + mockChannel.EnterTypingState().Returns(mockTypingState); + mockChannel + .SendMessageAsync(Arg.Any()) + .Returns(Task.FromResult<(string SentToChannel, string MessageContent)>(("test-channel", string.Empty))); + + client.FindChannel(serverName: Arg.Any(), channelName: Arg.Any()).Returns(mockChannel); + + await bot.PublishAsync( + builder: new EmbedBuilder().WithTitle("Test Message"), + cancellationToken: this.CancellationToken() + ); + + await mockChannel.Received(1).SendMessageAsync(Arg.Any()); + } + + [Fact] + public async Task PublishToReleaseChannelAsync_WhenChannelFound_SendsMessage() + { + (IDiscordRawClient client, _, DiscordBot bot) = this.CreateBot(); + + IDiscordChannel mockChannel = GetSubstitute(); + IDisposable mockTypingState = GetSubstitute(); + + mockChannel.Name.Returns("test-release-channel"); + mockChannel.EnterTypingState().Returns(mockTypingState); + mockChannel + .SendMessageAsync(Arg.Any()) + .Returns( + Task.FromResult<(string SentToChannel, string MessageContent)>(("test-release-channel", string.Empty)) + ); + + client.FindChannel(serverName: Arg.Any(), channelName: Arg.Any()).Returns(mockChannel); + + await bot.PublishToReleaseChannelAsync( + builder: new EmbedBuilder().WithTitle("Release Test"), + cancellationToken: this.CancellationToken() + ); + + await mockChannel.Received(1).SendMessageAsync(Arg.Any()); + } + + [Fact] + public async Task PublishAsync_WhenSendFails_WhileLoggedIn_ReconnectsAfterLogout() + { + (IDiscordRawClient client, _, DiscordBot bot) = this.CreateBot(); + + IDiscordChannel mockChannel = GetSubstitute(); + IDisposable mockTypingState = GetSubstitute(); + + mockChannel.Name.Returns("test-channel"); + mockChannel.EnterTypingState().Returns(mockTypingState); + mockChannel + .SendMessageAsync(Arg.Any()) + .Returns( + Task.FromException<(string SentToChannel, string MessageContent)>( + new InvalidOperationException("send failed") + ) + ); + + client.FindChannel(serverName: Arg.Any(), channelName: Arg.Any()).Returns(mockChannel); + client.LoginState.Returns(LoginState.LoggedIn); + + await bot.PublishAsync( + builder: new EmbedBuilder().WithTitle("Test"), + cancellationToken: this.CancellationToken() + ); + + await client.Received(1).LogoutAsync(); + await client.Received(1).StopAsync(); + await client.Received(1).LoginAsync(tokenType: TokenType.Bot, token: Config.Token); + } + + [Fact] + public async Task PublishAsync_WhenSendFails_WhileNotLoggedIn_ReconnectsWithoutLogout() + { + (IDiscordRawClient client, _, DiscordBot bot) = this.CreateBot(); + + IDiscordChannel mockChannel = GetSubstitute(); + IDisposable mockTypingState = GetSubstitute(); + + mockChannel.Name.Returns("test-channel"); + mockChannel.EnterTypingState().Returns(mockTypingState); + mockChannel + .SendMessageAsync(Arg.Any()) + .Returns( + Task.FromException<(string SentToChannel, string MessageContent)>( + new InvalidOperationException("send failed") + ) + ); + + client.FindChannel(serverName: Arg.Any(), channelName: Arg.Any()).Returns(mockChannel); + client.LoginState.Returns(LoginState.LoggedOut); + + await bot.PublishAsync( + builder: new EmbedBuilder().WithTitle("Test"), + cancellationToken: this.CancellationToken() + ); + + await client.DidNotReceive().LogoutAsync(); + await client.Received(1).LoginAsync(tokenType: TokenType.Bot, token: Config.Token); + } + + [Fact] + public async Task PublishAsync_WhenReconnectFails_LogsError() + { + (IDiscordRawClient client, ILogger logger, DiscordBot bot) = this.CreateBot(); + + IDiscordChannel mockChannel = GetSubstitute(); + IDisposable mockTypingState = GetSubstitute(); + + mockChannel.Name.Returns("test-channel"); + mockChannel.EnterTypingState().Returns(mockTypingState); + mockChannel + .SendMessageAsync(Arg.Any()) + .Returns( + Task.FromException<(string SentToChannel, string MessageContent)>( + new InvalidOperationException("send failed") + ) + ); + + client.FindChannel(serverName: Arg.Any(), channelName: Arg.Any()).Returns(mockChannel); + client.LoginState.Returns(LoginState.LoggedIn); + client.LogoutAsync().Returns(Task.FromException(new InvalidOperationException("logout failed"))); + + await bot.PublishAsync( + builder: new EmbedBuilder().WithTitle("Test"), + cancellationToken: this.CancellationToken() + ); + + logger.Received(2).IsEnabled(LogLevel.Error); + } + + [Fact] + public async Task StartAsync_CallsLoginStartAndSetGame() + { + (IDiscordRawClient client, _, DiscordBot bot) = this.CreateBot(); + + await bot.StartAsync(this.CancellationToken()); + + await client.Received(1).LoginAsync(tokenType: TokenType.Bot, token: Config.Token); + await client.Received(1).StartAsync(); + await client.Received(1).SetGameAsync(name: "GitHub", streamUrl: null, type: ActivityType.Watching); + } + + [Fact] + public async Task StopAsync_CallsLogout() + { + (IDiscordRawClient client, _, DiscordBot bot) = this.CreateBot(); + + await bot.StopAsync(this.CancellationToken()); + + await client.Received(1).LogoutAsync(); + } + + [Fact] + public async Task LogAsync_Debug_LogsAtDebugLevel() + { + (ILogger logger, Func handler) = this.CreateBotForLogTests(); + + await handler(new LogMessage(LogSeverity.Debug, source: "Test", message: "Debug message")); + + logger.Received(1).IsEnabled(LogLevel.Debug); + } + + [Fact] + public async Task LogAsync_Verbose_LogsAtInformationLevel() + { + (ILogger logger, Func handler) = this.CreateBotForLogTests(); + + await handler(new LogMessage(LogSeverity.Verbose, source: "Test", message: "Verbose message")); + + logger.Received(1).IsEnabled(LogLevel.Information); + } + + [Fact] + public async Task LogAsync_Info_LogsAtInformationLevel() + { + (ILogger logger, Func handler) = this.CreateBotForLogTests(); + + await handler(new LogMessage(LogSeverity.Info, source: "Test", message: "Info message")); + + logger.Received(1).IsEnabled(LogLevel.Information); + } + + [Fact] + public async Task LogAsync_Warning_LogsAtWarningLevel() + { + (ILogger logger, Func handler) = this.CreateBotForLogTests(); + + await handler(new LogMessage(LogSeverity.Warning, source: "Test", message: "Warning message")); + + logger.Received(1).IsEnabled(LogLevel.Warning); + } + + [Fact] + public async Task LogAsync_ErrorWithoutException_LogsAtErrorLevel() + { + (ILogger logger, Func handler) = this.CreateBotForLogTests(); + + await handler(new LogMessage(LogSeverity.Error, source: "Test", message: "Error message")); + + logger.Received(1).IsEnabled(LogLevel.Error); + } + + [Fact] + public async Task LogAsync_ErrorWithException_LogsAtErrorLevel() + { + (ILogger logger, Func handler) = this.CreateBotForLogTests(); + + await handler( + new LogMessage( + LogSeverity.Error, + source: "Test", + message: "Error message", + exception: new InvalidOperationException("test error") + ) + ); + + logger.Received(1).IsEnabled(LogLevel.Error); + } + + [Fact] + public async Task LogAsync_CriticalWithoutException_LogsAtCriticalLevel() + { + (ILogger logger, Func handler) = this.CreateBotForLogTests(); + + await handler(new LogMessage(LogSeverity.Critical, source: "Test", message: "Critical message")); + + logger.Received(1).IsEnabled(LogLevel.Critical); + } + + [Fact] + public async Task LogAsync_CriticalWithException_LogsAtCriticalLevel() + { + (ILogger logger, Func handler) = this.CreateBotForLogTests(); + + await handler( + new LogMessage( + LogSeverity.Critical, + source: "Test", + message: "Critical message", + exception: new InvalidOperationException("critical error") + ) + ); + + logger.Received(1).IsEnabled(LogLevel.Critical); + } + + [Fact] + public async Task LogAsync_UnknownSeverity_LogsAsCritical() + { + (ILogger logger, Func handler) = this.CreateBotForLogTests(); + + await handler(new LogMessage((LogSeverity)99, source: "Test", message: "Unknown severity message")); + + logger.Received(1).IsEnabled(LogLevel.Critical); + } +} diff --git a/src/BuildBot.Discord.Tests/Services/MessageChannelTests.cs b/src/BuildBot.Discord.Tests/Services/MessageChannelTests.cs new file mode 100644 index 000000000..97c80df71 --- /dev/null +++ b/src/BuildBot.Discord.Tests/Services/MessageChannelTests.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using BuildBot.Discord; +using BuildBot.Discord.Services; +using FunFair.Test.Common; +using Xunit; + +namespace BuildBot.Discord.Tests.Services; + +public sealed class MessageChannelTests : TestBase +{ + [Fact] + public async Task PublishAsync_ThenReadAllAsync_ReturnsPublishedMessage() + { + MessageChannel channel = new(); + const string testMessage = "hello"; + + await channel.PublishAsync(message: testMessage, cancellationToken: this.CancellationToken()); + + using CancellationTokenSource cts = new(); + cts.CancelAfter(5000); + + await using IAsyncEnumerator enumerator = channel.ReadAllAsync(cts.Token).GetAsyncEnumerator(cts.Token); + bool hasValue = await enumerator.MoveNextAsync(); + + Assert.True(condition: hasValue, userMessage: "Expected to receive a published message"); + + string received = enumerator.Current; + + Assert.Equal(expected: testMessage, actual: received); + } +} diff --git a/src/BuildBot.Discord/DiscordSetup.cs b/src/BuildBot.Discord/DiscordSetup.cs index cc9c3ae36..1eee85b41 100644 --- a/src/BuildBot.Discord/DiscordSetup.cs +++ b/src/BuildBot.Discord/DiscordSetup.cs @@ -10,6 +10,7 @@ public static IServiceCollection AddDiscord(this IServiceCollection services, Di { return services .AddSingleton(discordConfig) + .AddSingleton(_ => new DiscordSocketClientAdapter()) .AddSingleton() .AddSingleton(s => s.GetRequiredService()) .AddSingleton(s => s.GetRequiredService()) diff --git a/src/BuildBot.Discord/IDiscordBot.cs b/src/BuildBot.Discord/IDiscordBot.cs index 54cb69eac..a2883eca6 100644 --- a/src/BuildBot.Discord/IDiscordBot.cs +++ b/src/BuildBot.Discord/IDiscordBot.cs @@ -6,6 +6,10 @@ namespace BuildBot.Discord; public interface IDiscordBot { + Task StartAsync(CancellationToken cancellationToken); + + Task StopAsync(CancellationToken cancellationToken); + ValueTask PublishAsync(EmbedBuilder builder, CancellationToken cancellationToken); ValueTask PublishToReleaseChannelAsync(EmbedBuilder builder, CancellationToken cancellationToken); diff --git a/src/BuildBot.Discord/Services/BotService.cs b/src/BuildBot.Discord/Services/BotService.cs index 25a5be623..721bf18e7 100644 --- a/src/BuildBot.Discord/Services/BotService.cs +++ b/src/BuildBot.Discord/Services/BotService.cs @@ -11,14 +11,14 @@ namespace BuildBot.Discord.Services; public sealed class BotService : IHostedService, IDisposable { private static readonly TimeSpan InterMessageDelay = TimeSpan.FromSeconds(1); - private readonly DiscordBot _bot; + private readonly IDiscordBot _bot; private readonly IMessageChannel _botMessageChannel; private readonly IMessageChannel _botReleaseMessageChannel; private readonly IDisposable _messageSubscription; private readonly IDisposable _releaseMessageSubscription; public BotService( - DiscordBot bot, + IDiscordBot bot, IMessageChannel botMessageChannel, IMessageChannel botReleaseMessageChannel ) @@ -56,12 +56,12 @@ public void Dispose() public Task StartAsync(CancellationToken cancellationToken) { - return this._bot.StartAsync(); + return this._bot.StartAsync(cancellationToken); } public Task StopAsync(CancellationToken cancellationToken) { - return this._bot.StopAsync(); + return this._bot.StopAsync(cancellationToken); } private ValueTask PublishMessageAsync(BotMessage message, in CancellationToken cancellationToken) diff --git a/src/BuildBot.Discord/Services/DiscordBot.cs b/src/BuildBot.Discord/Services/DiscordBot.cs index 438254a42..3da7918db 100644 --- a/src/BuildBot.Discord/Services/DiscordBot.cs +++ b/src/BuildBot.Discord/Services/DiscordBot.cs @@ -1,12 +1,9 @@ using System; -using System.Linq; using System.Threading; using System.Threading.Tasks; using BuildBot.Discord.Services.LoggingExtensions; using BuildBot.ServiceModel.ComponentStatus; using Discord; -using Discord.Rest; -using Discord.WebSocket; using Microsoft.Extensions.Logging; namespace BuildBot.Discord.Services; @@ -15,24 +12,16 @@ public sealed class DiscordBot : IDiscordBot, IComponentStatus { private static readonly TimeSpan TypingDelay = TimeSpan.FromSeconds(2); private readonly DiscordBotConfiguration _botConfiguration; - private readonly DiscordSocketClient _client; + private readonly IDiscordRawClient _client; private readonly ILogger _logger; - private readonly SemaphoreSlim _semaphore = new(1); - public DiscordBot(DiscordBotConfiguration botConfiguration, ILogger logger) + public DiscordBot(IDiscordRawClient client, DiscordBotConfiguration botConfiguration, ILogger logger) { - this._logger = logger; - this._client = new( - new() - { - GatewayIntents = - GatewayIntents.Guilds | GatewayIntents.GuildMessageTyping | GatewayIntents.GuildMessages, - } - ); + this._client = client; this._botConfiguration = botConfiguration; - - this._client.Log += this.LogAsync; + this._logger = logger; + this._client.RegisterLogHandler(this.LogAsync); } private static EmbedAuthorBuilder Author { get; } = @@ -48,9 +37,9 @@ public ServiceStatus GetStatus() public async ValueTask PublishAsync(EmbedBuilder builder, CancellationToken cancellationToken) { - SocketTextChannel? socketTextChannel = this.GetChannel(this._botConfiguration.Channel); + IDiscordChannel? channel = this.GetChannel(this._botConfiguration.Channel); - if (socketTextChannel is null) + if (channel is null) { this._logger.LogDiscordChannelNotFound( channelName: this._botConfiguration.Channel, @@ -60,18 +49,14 @@ public async ValueTask PublishAsync(EmbedBuilder builder, CancellationToken canc return; } - await this.PublishCommonAsync( - builder: builder, - socketTextChannel: socketTextChannel, - cancellationToken: cancellationToken - ); + await this.PublishCommonAsync(builder: builder, channel: channel, cancellationToken: cancellationToken); } public async ValueTask PublishToReleaseChannelAsync(EmbedBuilder builder, CancellationToken cancellationToken) { - SocketTextChannel? socketTextChannel = this.GetChannel(this._botConfiguration.ReleaseChannel); + IDiscordChannel? channel = this.GetChannel(this._botConfiguration.ReleaseChannel); - if (socketTextChannel is null) + if (channel is null) { this._logger.LogDiscordChannelNotFound( channelName: this._botConfiguration.Channel, @@ -81,29 +66,25 @@ public async ValueTask PublishToReleaseChannelAsync(EmbedBuilder builder, Cancel return; } - await this.PublishCommonAsync( - builder: builder, - socketTextChannel: socketTextChannel, - cancellationToken: cancellationToken - ); + await this.PublishCommonAsync(builder: builder, channel: channel, cancellationToken: cancellationToken); } private async ValueTask PublishCommonAsync( EmbedBuilder builder, - SocketTextChannel socketTextChannel, + IDiscordChannel channel, CancellationToken cancellationToken ) { try { - this._logger.LogSendingMessage(channelName: socketTextChannel.Name, message: builder.Title); + this._logger.LogSendingMessage(channelName: channel.Name, message: builder.Title ?? string.Empty); - using (socketTextChannel.EnterTypingState()) + using (channel.EnterTypingState()) { Embed embed = IncludeStandardParameters(builder); - RestUserMessage msg = await socketTextChannel.SendMessageAsync(text: string.Empty, embed: embed); - this.LogSent(msg); + (string sentToChannel, string messageContent) = await channel.SendMessageAsync(embed); + this.LogSent(channelName: sentToChannel, content: messageContent); await Task.Delay(delay: TypingDelay, cancellationToken: cancellationToken); } } @@ -111,7 +92,7 @@ CancellationToken cancellationToken { this._logger.FailedToPublishMessage( channelName: this._botConfiguration.Channel, - title: builder.Title, + title: builder.Title ?? string.Empty, message: exception.Message, exception: exception ); @@ -120,9 +101,9 @@ CancellationToken cancellationToken } } - private void LogSent(RestUserMessage msg) + private void LogSent(string channelName, string content) { - this._logger.LogSentMessage(channelName: msg.Channel.Name, message: msg.CleanContent); + this._logger.LogSentMessage(channelName: channelName, message: content); } private static Embed IncludeStandardParameters(EmbedBuilder builder) @@ -130,15 +111,9 @@ private static Embed IncludeStandardParameters(EmbedBuilder builder) return builder.WithAuthor(Author).Build(); } - private SocketTextChannel? GetChannel(string channelName) + private IDiscordChannel? GetChannel(string channelName) { - SocketGuild? guild = this._client.Guilds.FirstOrDefault(predicate: g => - StringComparer.Ordinal.Equals(x: g.Name, y: this._botConfiguration.Server) - ); - - return guild?.TextChannels.FirstOrDefault(predicate: c => - StringComparer.OrdinalIgnoreCase.Equals(x: c.Name, y: channelName) - ); + return this._client.FindChannel(serverName: this._botConfiguration.Server, channelName: channelName); } private Task LogAsync(LogMessage arg) @@ -204,20 +179,15 @@ private Task LogDebugAsync(LogMessage arg) return Task.CompletedTask; } - public async Task StartAsync() + public async Task StartAsync(CancellationToken cancellationToken) { - // login await this._client.LoginAsync(tokenType: TokenType.Bot, token: this._botConfiguration.Token); - - // and start await this._client.StartAsync(); - await this._client.SetGameAsync(name: "GitHub", streamUrl: null, type: ActivityType.Watching); } - public Task StopAsync() + public Task StopAsync(CancellationToken cancellationToken) { - // and logout return this._client.LogoutAsync(); } @@ -234,9 +204,7 @@ private async ValueTask ReconnectAsync(CancellationToken cancellationToken) } await this._client.LoginAsync(tokenType: TokenType.Bot, token: this._botConfiguration.Token); - await this._client.StartAsync(); - await this._client.SetGameAsync(name: "GitHub", streamUrl: null, type: ActivityType.Watching); } catch (Exception exception) diff --git a/src/BuildBot.Discord/Services/DiscordChannelAdapter.cs b/src/BuildBot.Discord/Services/DiscordChannelAdapter.cs new file mode 100644 index 000000000..cf64a6cda --- /dev/null +++ b/src/BuildBot.Discord/Services/DiscordChannelAdapter.cs @@ -0,0 +1,31 @@ +using System; +using System.Threading.Tasks; +using Discord; +using Discord.Rest; +using Discord.WebSocket; + +namespace BuildBot.Discord.Services; + +internal sealed class DiscordChannelAdapter : IDiscordChannel +{ + private readonly SocketTextChannel _channel; + + public DiscordChannelAdapter(SocketTextChannel channel) + { + this._channel = channel; + } + + public string Name => this._channel.Name; + + public IDisposable EnterTypingState() + { + return this._channel.EnterTypingState(); + } + + public async Task<(string SentToChannel, string MessageContent)> SendMessageAsync(Embed embed) + { + RestUserMessage msg = await this._channel.SendMessageAsync(text: string.Empty, embed: embed); + + return (msg.Channel.Name, msg.CleanContent); + } +} diff --git a/src/BuildBot.Discord/Services/DiscordSocketClientAdapter.cs b/src/BuildBot.Discord/Services/DiscordSocketClientAdapter.cs new file mode 100644 index 000000000..bc7c47ad8 --- /dev/null +++ b/src/BuildBot.Discord/Services/DiscordSocketClientAdapter.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.WebSocket; + +namespace BuildBot.Discord.Services; + +internal sealed class DiscordSocketClientAdapter : IDiscordRawClient +{ + private readonly DiscordSocketClient _client; + + public DiscordSocketClientAdapter() + { + this._client = new( + new() + { + GatewayIntents = + GatewayIntents.Guilds | GatewayIntents.GuildMessageTyping | GatewayIntents.GuildMessages, + } + ); + } + + public LoginState LoginState => this._client.LoginState; + + public IDiscordChannel? FindChannel(string serverName, string channelName) + { + SocketGuild? guild = this._client.Guilds.FirstOrDefault(predicate: g => + StringComparer.Ordinal.Equals(x: g.Name, y: serverName) + ); + + SocketTextChannel? textChannel = guild?.TextChannels.FirstOrDefault(predicate: c => + StringComparer.OrdinalIgnoreCase.Equals(x: c.Name, y: channelName) + ); + + return textChannel is null ? null : new DiscordChannelAdapter(textChannel); + } + + public Task LoginAsync(TokenType tokenType, string token) + { + return this._client.LoginAsync(tokenType: tokenType, token: token); + } + + public Task StartAsync() + { + return this._client.StartAsync(); + } + + public Task StopAsync() + { + return this._client.StopAsync(); + } + + public Task LogoutAsync() + { + return this._client.LogoutAsync(); + } + + public Task SetGameAsync(string name, string? streamUrl, ActivityType type) + { + return this._client.SetGameAsync(name: name, streamUrl: streamUrl, type: type); + } + + public void RegisterLogHandler(Func handler) + { + this._client.Log += handler; + } +} diff --git a/src/BuildBot.Discord/Services/IDiscordChannel.cs b/src/BuildBot.Discord/Services/IDiscordChannel.cs new file mode 100644 index 000000000..210ea45b2 --- /dev/null +++ b/src/BuildBot.Discord/Services/IDiscordChannel.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading.Tasks; +using Discord; + +namespace BuildBot.Discord.Services; + +public interface IDiscordChannel +{ + string Name { get; } + + IDisposable EnterTypingState(); + + Task<(string SentToChannel, string MessageContent)> SendMessageAsync(Embed embed); +} diff --git a/src/BuildBot.Discord/Services/IDiscordRawClient.cs b/src/BuildBot.Discord/Services/IDiscordRawClient.cs new file mode 100644 index 000000000..c32136598 --- /dev/null +++ b/src/BuildBot.Discord/Services/IDiscordRawClient.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading.Tasks; +using Discord; + +namespace BuildBot.Discord.Services; + +public interface IDiscordRawClient +{ + LoginState LoginState { get; } + + IDiscordChannel? FindChannel(string serverName, string channelName); + + Task LoginAsync(TokenType tokenType, string token); + + Task StartAsync(); + + Task StopAsync(); + + Task LogoutAsync(); + + Task SetGameAsync(string name, string? streamUrl, ActivityType type); + + void RegisterLogHandler(Func handler); +} diff --git a/src/BuildBot.slnx b/src/BuildBot.slnx index 8dc5fe85f..11bb2eda9 100644 --- a/src/BuildBot.slnx +++ b/src/BuildBot.slnx @@ -13,6 +13,7 @@ + diff --git a/src/UnitTests.props b/src/UnitTests.props index c4cad6d45..db1df56e1 100644 --- a/src/UnitTests.props +++ b/src/UnitTests.props @@ -48,9 +48,26 @@ <_CoverageSettingsLines Include=" <Attribute>^System%5C.CodeDom%5C.Compiler%5C.GeneratedCodeAttribute$</Attribute>" /> <_CoverageSettingsLines Include=" </Exclude>" /> <_CoverageSettingsLines Include=" </Attributes>" /> + + + + + <_CoverageSettingsLines Include=" <Functions>" /> + <_CoverageSettingsLines Include=" <Exclude>" /> + + + <_CoverageSettingsLines Include=" <Function>%(_CoverageExcludedFunctions.Identity)</Function>" /> + + + <_CoverageSettingsLines Include=" </Exclude>" /> + <_CoverageSettingsLines Include=" </Functions>" /> + + + <_CoverageSettingsLines Include=" </CodeCoverage>" /> <_CoverageSettingsLines Include="</Configuration>" /> + Date: Sun, 21 Jun 2026 13:07:40 +0000 Subject: [PATCH 2/4] test(discord): Add IDiscordRawClient DI tests and remove invalid coverage exclusions Remove _CoverageExcludedFunctions exclusions that violated project rules against suppressing infrastructure-dependent coverage gaps, and add three new DI tests that exercise DiscordSocketClientAdapter directly through the registered IDiscordRawClient: verifies the service is registered, LoginState is LoggedOut when disconnected, and FindChannel returns null when the client has no guilds. Prompt: Work on pull request #373 in funfair-tech/BuildBot. --- .../BuildBot.Discord.Tests.csproj | 5 ---- .../DependencyInjectionTests.cs | 26 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/BuildBot.Discord.Tests/BuildBot.Discord.Tests.csproj b/src/BuildBot.Discord.Tests/BuildBot.Discord.Tests.csproj index 5f19f1d1b..621c3d741 100644 --- a/src/BuildBot.Discord.Tests/BuildBot.Discord.Tests.csproj +++ b/src/BuildBot.Discord.Tests/BuildBot.Discord.Tests.csproj @@ -40,11 +40,6 @@ - - - <_CoverageExcludedFunctions Include=".*DiscordSocketClientAdapter.*" /> - <_CoverageExcludedFunctions Include=".*DiscordChannelAdapter.*" /> - diff --git a/src/BuildBot.Discord.Tests/DependencyInjectionTests.cs b/src/BuildBot.Discord.Tests/DependencyInjectionTests.cs index 2d0ae632c..8997602d1 100644 --- a/src/BuildBot.Discord.Tests/DependencyInjectionTests.cs +++ b/src/BuildBot.Discord.Tests/DependencyInjectionTests.cs @@ -1,6 +1,8 @@ using BuildBot.Discord; using BuildBot.Discord.Models; +using BuildBot.Discord.Services; using BuildBot.ServiceModel.ComponentStatus; +using Discord; using FunFair.Test.Common; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -54,4 +56,28 @@ public void BotServiceMustBeRegisteredAsIHostedService() { this.RequireService(); } + + [Fact] + public void DiscordRawClientMustBeRegistered() + { + this.RequireService(); + } + + [Fact] + public void DiscordRawClient_WhenDisconnected_LoginStateIsLoggedOut() + { + IDiscordRawClient client = this.GetService(); + Assert.Equal(expected: LoginState.LoggedOut, actual: client.LoginState); + } + + [Fact] + public void FindChannel_WhenClientIsDisconnected_ReturnsNull() + { + IDiscordRawClient client = this.GetService(); + IDiscordChannel? channel = client.FindChannel( + serverName: "nonexistent-server", + channelName: "nonexistent-channel" + ); + Assert.Null(channel); + } } From af08f5c4baeb5000fb9ccc2ea68c773af385ed0c Mon Sep 17 00:00:00 2001 From: Mark Ridgwell <273118822+dnyw4l3n13@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:34:04 +0000 Subject: [PATCH 3/4] test(discord): Add branch coverage for null-title paths in DiscordBot.PublishCommonAsync Adds two tests to cover the uncovered branches in PublishCommonAsync where builder.Title is null, triggering the null-coalescing fallback to string.Empty on both the success path (LogSendingMessage) and the failure/catch path (FailedToPublishMessage). Prompt: Work on pull request #373 in funfair-tech/BuildBot. --- .../Services/DiscordBotTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/BuildBot.Discord.Tests/Services/DiscordBotTests.cs b/src/BuildBot.Discord.Tests/Services/DiscordBotTests.cs index 0eed2538a..4a3b22b02 100644 --- a/src/BuildBot.Discord.Tests/Services/DiscordBotTests.cs +++ b/src/BuildBot.Discord.Tests/Services/DiscordBotTests.cs @@ -378,4 +378,52 @@ public async Task LogAsync_UnknownSeverity_LogsAsCritical() logger.Received(1).IsEnabled(LogLevel.Critical); } + + [Fact] + public async Task PublishAsync_WhenChannelFound_WithNullTitle_SendsMessage() + { + (IDiscordRawClient client, _, DiscordBot bot) = this.CreateBot(); + + IDiscordChannel mockChannel = GetSubstitute(); + IDisposable mockTypingState = GetSubstitute(); + + mockChannel.Name.Returns("test-channel"); + mockChannel.EnterTypingState().Returns(mockTypingState); + mockChannel + .SendMessageAsync(Arg.Any()) + .Returns(Task.FromResult<(string SentToChannel, string MessageContent)>(("test-channel", string.Empty))); + + client.FindChannel(serverName: Arg.Any(), channelName: Arg.Any()).Returns(mockChannel); + + await bot.PublishAsync(builder: new EmbedBuilder(), cancellationToken: this.CancellationToken()); + + await mockChannel.Received(1).SendMessageAsync(Arg.Any()); + } + + [Fact] + public async Task PublishAsync_WhenSendFails_WithNullTitle_LogsErrorAndReconnects() + { + (IDiscordRawClient client, ILogger logger, DiscordBot bot) = this.CreateBot(); + + IDiscordChannel mockChannel = GetSubstitute(); + IDisposable mockTypingState = GetSubstitute(); + + mockChannel.Name.Returns("test-channel"); + mockChannel.EnterTypingState().Returns(mockTypingState); + mockChannel + .SendMessageAsync(Arg.Any()) + .Returns( + Task.FromException<(string SentToChannel, string MessageContent)>( + new InvalidOperationException("send failed") + ) + ); + + client.FindChannel(serverName: Arg.Any(), channelName: Arg.Any()).Returns(mockChannel); + client.LoginState.Returns(LoginState.LoggedOut); + + await bot.PublishAsync(builder: new EmbedBuilder(), cancellationToken: this.CancellationToken()); + + logger.Received(1).IsEnabled(LogLevel.Error); + await client.DidNotReceive().LogoutAsync(); + } } From 36741491708cc23e1ebfa2eadf6ee17ed6529f9e Mon Sep 17 00:00:00 2001 From: Mark Ridgwell <273118822+dnyw4l3n13@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:37:57 +0000 Subject: [PATCH 4/4] chore(changelog): Update CHANGELOG for null-title branch coverage tests Prompt: Work on pull request #373 in funfair-tech/BuildBot. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd8c55b6..54f60b335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Please ADD ALL Changes to the UNRELEASED SECTION and not a specific release - Dependencies - Updated Discord.Net to 3.19.1 - Dependencies - Updated Microsoft.Extensions to 10.0.5 - SDK - Updated DotNet SDK to 10.0.301 +- BuildBot.Discord.Tests: Added branch coverage tests for null-title paths in DiscordBot.PublishCommonAsync ### Removed ### Deployment Changes