Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Please ADD ALL Changes to the UNRELEASED SECTION and not a specific release
- SnsMessage: Token property was never populated from the constructor argument
- Added null guards for botMessageChannel and botReleaseMessageChannel parameters in BotService constructor
- Pass ILogger to HealthCheckClient.ExecuteAsync to match new API in Credfeto.Docker.HealthCheck.Http.Client 0.0.72.928
- Made DiscordChannelAdapter testable by accepting ITextChannel instead of the sealed SocketTextChannel, and added unit tests
### Changed
- Dependencies - Updated NSubstitute.Analyzers.CSharp to 1.0.17
- Switched to use minimal APIs
Expand Down
2 changes: 1 addition & 1 deletion src/BuildBot.Discord.Tests/BuildBot.Discord.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,4 @@
<PackageReference Include="ToStringWithoutOverrideAnalyzer" Version="0.6.0" PrivateAssets="All" ExcludeAssets="runtime" />
<PackageReference Include="xunit.analyzers" Version="1.27.0" PrivateAssets="All" ExcludeAssets="runtime" />
</ItemGroup>
</Project>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System;
using System.Threading.Tasks;
using BuildBot.Discord.Services;
using Discord;
using FunFair.Test.Common;
using NSubstitute;
using Xunit;

namespace BuildBot.Discord.Tests.Services;

public sealed class DiscordChannelAdapterTests : TestBase
{
[Fact]
public void Name_ReturnsChannelName()
{
ITextChannel channel = GetSubstitute<ITextChannel>();
channel.Name.Returns("test-channel");

DiscordChannelAdapter adapter = new(channel);

Assert.Equal(expected: "test-channel", actual: adapter.Name);
}

[Fact]
public void EnterTypingState_ReturnsTypingStateFromChannel()
{
ITextChannel channel = GetSubstitute<ITextChannel>();
IDisposable typingState = GetSubstitute<IDisposable>();
channel.EnterTypingState(Arg.Any<RequestOptions>()).Returns(typingState);

DiscordChannelAdapter adapter = new(channel);

IDisposable result = adapter.EnterTypingState();

Assert.Same(expected: typingState, actual: result);
}

[Fact]
public async Task SendMessageAsync_ReturnsChannelNameAndCleanContent()
{
ITextChannel channel = GetSubstitute<ITextChannel>();
IUserMessage message = GetSubstitute<IUserMessage>();

channel.Name.Returns("sent-channel");

// In production CleanContent is always "" because the adapter sends text: string.Empty (embed-only).
message.CleanContent.Returns("clean content");

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GatewayIntents.MessageContent is absent from the intents registered in DiscordSocketClientAdapter (only Guilds | GuildMessageTyping | GuildMessages are set). Discord's gateway strips message content when this intent is not enabled, so msg.CleanContent always returns "" in production.

The test stubs message.CleanContent.Returns("clean content") and passes, but the actual production path silently returns an empty string. The test gives false confidence that CleanContent contains useful data.

Consider either:

  1. Adding GatewayIntents.MessageContent to the registered intents in DiscordSocketClientAdapter, or
  2. Adding a comment in the test noting that CleanContent is expected to be empty in production due to the missing intent.


channel.SendMessageAsync(text: string.Empty, embed: default).ReturnsForAnyArgs(Task.FromResult(message));

DiscordChannelAdapter adapter = new(channel);

Embed embed = new EmbedBuilder().Build();
(string sentToChannel, string messageContent) = await adapter.SendMessageAsync(embed);

Assert.Equal(expected: "sent-channel", actual: sentToChannel);
Assert.Equal(expected: "clean content", actual: messageContent);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test sets up the mock with Arg.Any<>() for all parameters, so it doesn't verify that SendMessageAsync was called with the correct embed or text: string.Empty. A broken adapter that passed default(Embed) or "hello" as text would still make both assertions pass.

Add a Received() call to verify the channel was called with the expected arguments:

await channel
    .Received(1)
    .SendMessageAsync(
        text: string.Empty,
        tts: false,
        embed: embed,
        options: Arg.Any<RequestOptions>(),
        allowedMentions: Arg.Any<AllowedMentions>(),
        messageReference: Arg.Any<MessageReference>(),
        components: Arg.Any<MessageComponent>(),
        stickers: Arg.Any<ISticker[]>(),
        embeds: Arg.Any<Embed[]>(),
        flags: Arg.Any<MessageFlags>(),
        poll: Arg.Any<PollProperties>()
    );


await channel
.Received(1)
.SendMessageAsync(
text: string.Empty,
isTTS: Arg.Any<bool>(),
embed: embed,
options: Arg.Any<RequestOptions>(),
allowedMentions: Arg.Any<AllowedMentions>(),
messageReference: Arg.Any<MessageReference>(),
components: Arg.Any<MessageComponent>(),
stickers: Arg.Any<ISticker[]>(),
embeds: Arg.Any<Embed[]>(),
flags: Arg.Any<MessageFlags>(),
poll: Arg.Any<PollProperties>()
);
}
}
14 changes: 6 additions & 8 deletions src/BuildBot.Discord/Services/DiscordChannelAdapter.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
using System;
using System.Threading.Tasks;
using Discord;
using Discord.Rest;
using Discord.WebSocket;

namespace BuildBot.Discord.Services;

internal sealed class DiscordChannelAdapter : IDiscordChannel
public sealed class DiscordChannelAdapter : IDiscordChannel

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DiscordChannelAdapter was made public to enable direct instantiation in the test assembly, but the PR description itself mentions [assembly: InternalsVisibleTo("BuildBot.Discord.Tests")] as the intended approach — and that attribute was never added. The sibling DiscordSocketClientAdapter remains internal. Making this adapter public unnecessarily widens the library's API surface: external callers can now directly depend on the concrete type and its ITextChannel constructor parameter (a Discord.Net third-party interface), locking in an implementation detail that was previously encapsulated.

The correct fix is to revert to internal and add InternalsVisibleTo to the production project:

[assembly: InternalsVisibleTo("BuildBot.Discord.Tests")]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted — FunFair.CodeAnalysis rule FFS0051 explicitly prohibits [assembly: InternalsVisibleTo] in this project, so making the class public is the correct approach given the project constraints. Finding withdrawn.

{
private readonly SocketTextChannel _channel;
private readonly ITextChannel _channel;

public DiscordChannelAdapter(SocketTextChannel channel)
public DiscordChannelAdapter(ITextChannel channel)
{
this._channel = channel;
this._channel = channel ?? throw new ArgumentNullException(nameof(channel));
}

public string Name => this._channel.Name;
Expand All @@ -24,8 +22,8 @@ public IDisposable EnterTypingState()

public async Task<(string SentToChannel, string MessageContent)> SendMessageAsync(Embed embed)
{
RestUserMessage msg = await this._channel.SendMessageAsync(text: string.Empty, embed: embed);
IUserMessage msg = await this._channel.SendMessageAsync(text: string.Empty, embed: embed);

return (msg.Channel.Name, msg.CleanContent);
return (this._channel.Name, msg.CleanContent);
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

msg.Channel.Name navigates through the returned IUserMessage to get the channel name, but the adapter already holds this._channel.Name directly. The indirection requires the test to stub message.Channel.Returns(channel) solely for this path — using this._channel.Name would be simpler and eliminate that extra stub.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a37cac5 — switched to this._channel.Name directly and removed the now-unnecessary message.Channel.Returns(channel) stub from the test.

}
Loading