Skip to content

feat: make DiscordChannelAdapter testable and add unit tests#387

Open
dnyw4l3n13 wants to merge 10 commits into
mainfrom
feature/374-discord-channel-adapter-testable
Open

feat: make DiscordChannelAdapter testable and add unit tests#387
dnyw4l3n13 wants to merge 10 commits into
mainfrom
feature/374-discord-channel-adapter-testable

Conversation

@dnyw4l3n13

Copy link
Copy Markdown
Collaborator

Summary

Makes DiscordChannelAdapter testable by:

  • Changing constructor parameter from the sealed SocketTextChannel to the ITextChannel interface
  • Widening RestUserMessage return type to IUserMessage in SendMessageAsync
  • Adding [assembly: InternalsVisibleTo("BuildBot.Discord.Tests")] so tests can instantiate the internal class
  • Adding explicit Discord.Net package reference to the test project
  • Adding unit tests for Name, EnterTypingState, and SendMessageAsync in DiscordChannelAdapter

Test plan

  • Name_ReturnsChannelName — verifies Name delegates to ITextChannel.Name
  • EnterTypingState_ReturnsTypingStateFromChannel — verifies EnterTypingState() returns the IDisposable from the channel
  • SendMessageAsync_ReturnsChannelNameAndCleanContent — verifies the tuple mapping from IUserMessage

Closes #374

@dnyw4l3n13 dnyw4l3n13 self-assigned this Jul 2, 2026
@dnyw4l3n13 dnyw4l3n13 added AI-Work Work for an AI Agent Tech Debt Technical debt buildbot-discord Changes in BuildBot.Discord project labels Jul 2, 2026
dnyw4l3n13 added a commit that referenced this pull request Jul 2, 2026
- Changed DiscordChannelAdapter constructor from sealed SocketTextChannel to ITextChannel interface
- Changed SendMessageAsync return type from RestUserMessage to IUserMessage
- Made DiscordChannelAdapter public so tests can instantiate it directly (InternalsVisibleTo is prohibited by FFS0051)
- Added explicit Discord.Net package reference to BuildBot.Discord.Tests project
- Added unit tests for Name, EnterTypingState, and SendMessageAsync

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
@dnyw4l3n13

Copy link
Copy Markdown
Collaborator Author

Implemented missing code changes in commit b44b951: changed DiscordChannelAdapter constructor from SocketTextChannel to ITextChannel, widened return type to IUserMessage, made class public (InternalsVisibleTo is prohibited by FFS0051), added explicit Discord.Net reference to test project, and added unit tests for Name, EnterTypingState, and SendMessageAsync. CI running; next cycle will continue.

(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>()
    );


messageChannel.Name.Returns("sent-channel");
message.Channel.Returns(messageChannel);
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.

message.Channel.Returns(messageChannel);
message.CleanContent.Returns("clean content");

channel

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.

Listing all 11 Arg.Any<>() matchers explicitly is brittle: if Discord.Net adds a parameter to SendMessageAsync in a future version, NSubstitute will silently stop matching the call (the setup won't fire, the method returns null, and the test throws NullReferenceException rather than a clear assertion failure).

ReturnsForAnyArgs is immune to parameter count changes:

channel.SendMessageAsync(default!, default, default, default, default, default, default, default!, default!, default, default)
    .ReturnsForAnyArgs(Task.FromResult(message));

{
ITextChannel channel = GetSubstitute<ITextChannel>();
IUserMessage message = GetSubstitute<IUserMessage>();
IMessageChannel messageChannel = GetSubstitute<IMessageChannel>();

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.

ITextChannel extends IMessageChannel, so the channel substitute already satisfies the IMessageChannel type required by message.Channel. The extra messageChannel substitute and its Returns setup can be removed:

channel.Name.Returns("sent-channel");
message.Channel.Returns(channel);  // channel IS an IMessageChannel

This removes three lines and makes the data flow clearer: the channel the message is sent to is the same channel whose name appears in the result.

@dnyw4l3n13

Copy link
Copy Markdown
Collaborator Author

Code review round: fixed 4 findings — removed unnecessary IMessageChannel substitute, switched to ReturnsForAnyArgs for mock resilience, added Received(1) assertion to verify embed forwarding, and clarified CleanContent pass-through behaviour in test comment. Next cycle will continue with security review if CI is green.

IUserMessage msg = await this._channel.SendMessageAsync(text: string.Empty, embed: embed);

return (msg.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.

</ItemGroup>
<ItemGroup>
<PackageReference Include="Discord.Net" Version="3.20.1" />
<PackageReference Include="FunFair.Test.Common" Version="6.3.2.2390" />

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.

Discord.Net is already a transitive dependency of this test project via the ProjectReference to BuildBot.Discord.csproj. Adding an explicit PackageReference here creates a version-skew risk: if the production project bumps Discord.Net, the test project stays pinned to 3.20.1 and the two projects see different interface shapes at build time. No other test project in this repository re-declares transitive production dependencies — the test csproj should rely solely on the ProjectReference for Discord.Net types.

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 488d96c — removed the explicit Discord.Net PackageReference; the types are available transitively via the ProjectReference to BuildBot.Discord.csproj.

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.

public DiscordChannelAdapter(SocketTextChannel channel)
public DiscordChannelAdapter(ITextChannel channel)
{
this._channel = channel;

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 constructor parameter channel has no null guard. Project convention in this service layer (e.g. BotService) guards all injected parameters with ?? throw new ArgumentNullException(nameof(...)). Since the class is now public, this gap is reachable from any caller, not just the internal factory that currently guards it before construction.

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 e6bbfa6 — added null-guard to match project convention.

message.Channel.Returns(channel);

// In production CleanContent is always "" because the adapter sends text: string.Empty (embed-only).
// This stub verifies the adapter correctly passes through whatever CleanContent the message returns.

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.

Line 48 ("This stub verifies the adapter correctly passes through whatever CleanContent the message returns.") describes what the test does rather than a non-obvious why, which the code-quality.instructions.md rule flags as an unacceptable comment. The non-obvious constraint that CleanContent is always "" in production (line 47) is the acceptable part, but line 48's description of the test's intent is inferable from the test name and structure and should be removed.

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 ff19af1 — removed the second comment line.

@dnyw4l3n13

Copy link
Copy Markdown
Collaborator Author

Code review complete — 2 confirmed findings and 1 plausible finding posted as inline comments: (1) class should be internal + InternalsVisibleTo rather than public, (2) missing null-guard on constructor parameter, (3) test comment line 48 describes what the test does rather than a non-obvious why. Fixing now.

@dnyw4l3n13

Copy link
Copy Markdown
Collaborator Author

Code review round: fixed 2 findings — added null-guard on DiscordChannelAdapter constructor parameter (e6bbfa6), removed redundant test comment describing test intent (ff19af1). Note: the InternalsVisibleTo finding was withdrawn as FunFair.CodeAnalysis rule FFS0051 prohibits that pattern; making the class public is the correct approach for this project.

dnyw4l3n13 added a commit that referenced this pull request Jul 2, 2026
… of navigating msg.Channel.Name

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
dnyw4l3n13 added a commit that referenced this pull request Jul 2, 2026
…est project

Discord.Net is available transitively via the ProjectReference to BuildBot.Discord.csproj,
so declaring it explicitly creates version-skew risk.

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
@dnyw4l3n13

Copy link
Copy Markdown
Collaborator Author

Code review clean — fixed 2 remaining inline findings (use this._channel.Name directly, remove redundant Discord.Net PackageReference) and advancing to security review.

@dnyw4l3n13

Copy link
Copy Markdown
Collaborator Author

Security review clean — no findings. Advancing to Human Review.

@dnyw4l3n13 dnyw4l3n13 marked this pull request as ready for review July 2, 2026 20:42
@dnyw4l3n13 dnyw4l3n13 requested a review from a team as a code owner July 2, 2026 20:42
@dnyw4l3n13 dnyw4l3n13 enabled auto-merge July 2, 2026 20:42
@dnyw4l3n13

Copy link
Copy Markdown
Collaborator Author

PR is ready for merge — marked ready for review, auto-merge enabled. Code review and security review both clean.

@credfeto

credfeto commented Jul 2, 2026

Copy link
Copy Markdown
Member

Super-linter summary

Language Validation result

All files and directories linted successfully

For more information, see the GitHub Actions workflow run

Powered by Super-linter

dnyw4l3n13 added a commit that referenced this pull request Jul 2, 2026
- Changed DiscordChannelAdapter constructor from sealed SocketTextChannel to ITextChannel interface
- Changed SendMessageAsync return type from RestUserMessage to IUserMessage
- Made DiscordChannelAdapter public so tests can instantiate it directly (InternalsVisibleTo is prohibited by FFS0051)
- Added explicit Discord.Net package reference to BuildBot.Discord.Tests project
- Added unit tests for Name, EnterTypingState, and SendMessageAsync

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
dnyw4l3n13 added a commit that referenced this pull request Jul 2, 2026
… of navigating msg.Channel.Name

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
dnyw4l3n13 added a commit that referenced this pull request Jul 2, 2026
…est project

Discord.Net is available transitively via the ProjectReference to BuildBot.Discord.csproj,
so declaring it explicitly creates version-skew risk.

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
dnyw4l3n13 added a commit that referenced this pull request Jul 2, 2026
… of navigating msg.Channel.Name

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
@dnyw4l3n13 dnyw4l3n13 force-pushed the feature/374-discord-channel-adapter-testable branch from 7dcfbc1 to afd60ec Compare July 2, 2026 23:00
dnyw4l3n13 added a commit that referenced this pull request Jul 2, 2026
…est project

Discord.Net is available transitively via the ProjectReference to BuildBot.Discord.csproj,
so declaring it explicitly creates version-skew risk.

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
dnyw4l3n13 added a commit that referenced this pull request Jul 3, 2026
- Changed DiscordChannelAdapter constructor from sealed SocketTextChannel to ITextChannel interface
- Changed SendMessageAsync return type from RestUserMessage to IUserMessage
- Made DiscordChannelAdapter public so tests can instantiate it directly (InternalsVisibleTo is prohibited by FFS0051)
- Added explicit Discord.Net package reference to BuildBot.Discord.Tests project
- Added unit tests for Name, EnterTypingState, and SendMessageAsync

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
dnyw4l3n13 added a commit that referenced this pull request Jul 3, 2026
… of navigating msg.Channel.Name

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
dnyw4l3n13 added a commit that referenced this pull request Jul 3, 2026
…est project

Discord.Net is available transitively via the ProjectReference to BuildBot.Discord.csproj,
so declaring it explicitly creates version-skew risk.

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
@dnyw4l3n13 dnyw4l3n13 force-pushed the feature/374-discord-channel-adapter-testable branch from afd60ec to b982f19 Compare July 3, 2026 03:40
dnyw4l3n13 added a commit that referenced this pull request Jul 3, 2026
- Changed DiscordChannelAdapter constructor from sealed SocketTextChannel to ITextChannel interface
- Changed SendMessageAsync return type from RestUserMessage to IUserMessage
- Made DiscordChannelAdapter public so tests can instantiate it directly (InternalsVisibleTo is prohibited by FFS0051)
- Added explicit Discord.Net package reference to BuildBot.Discord.Tests project
- Added unit tests for Name, EnterTypingState, and SendMessageAsync

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
dnyw4l3n13 added a commit that referenced this pull request Jul 3, 2026
… of navigating msg.Channel.Name

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
dnyw4l3n13 added a commit that referenced this pull request Jul 3, 2026
…est project

Discord.Net is available transitively via the ProjectReference to BuildBot.Discord.csproj,
so declaring it explicitly creates version-skew risk.

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
@dnyw4l3n13 dnyw4l3n13 force-pushed the feature/374-discord-channel-adapter-testable branch from b982f19 to 690d2ae Compare July 3, 2026 13:15
dnyw4l3n13 added a commit that referenced this pull request Jul 4, 2026
- Changed DiscordChannelAdapter constructor from sealed SocketTextChannel to ITextChannel interface
- Changed SendMessageAsync return type from RestUserMessage to IUserMessage
- Made DiscordChannelAdapter public so tests can instantiate it directly (InternalsVisibleTo is prohibited by FFS0051)
- Added explicit Discord.Net package reference to BuildBot.Discord.Tests project
- Added unit tests for Name, EnterTypingState, and SendMessageAsync

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
@dnyw4l3n13 dnyw4l3n13 force-pushed the feature/374-discord-channel-adapter-testable branch from 690d2ae to c0ee30c Compare July 4, 2026 10:56
dnyw4l3n13 added a commit that referenced this pull request Jul 4, 2026
… of navigating msg.Channel.Name

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
dnyw4l3n13 added a commit that referenced this pull request Jul 4, 2026
…est project

Discord.Net is available transitively via the ProjectReference to BuildBot.Discord.csproj,
so declaring it explicitly creates version-skew risk.

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
dnyw4l3n13 added a commit that referenced this pull request Jul 4, 2026
- Changed DiscordChannelAdapter constructor from sealed SocketTextChannel to ITextChannel interface
- Changed SendMessageAsync return type from RestUserMessage to IUserMessage
- Made DiscordChannelAdapter public so tests can instantiate it directly (InternalsVisibleTo is prohibited by FFS0051)
- Added explicit Discord.Net package reference to BuildBot.Discord.Tests project
- Added unit tests for Name, EnterTypingState, and SendMessageAsync

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
dnyw4l3n13 added a commit that referenced this pull request Jul 4, 2026
… of navigating msg.Channel.Name

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
dnyw4l3n13 added a commit that referenced this pull request Jul 4, 2026
…est project

Discord.Net is available transitively via the ProjectReference to BuildBot.Discord.csproj,
so declaring it explicitly creates version-skew risk.

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
@dnyw4l3n13 dnyw4l3n13 force-pushed the feature/374-discord-channel-adapter-testable branch from c0ee30c to 22defff Compare July 4, 2026 15:16
dnyw4l3n13 and others added 10 commits July 4, 2026 16:35
Prompt: Work on issue #374 in funfair-tech/BuildBot.
- Changed DiscordChannelAdapter constructor from sealed SocketTextChannel to ITextChannel interface
- Changed SendMessageAsync return type from RestUserMessage to IUserMessage
- Made DiscordChannelAdapter public so tests can instantiate it directly (InternalsVisibleTo is prohibited by FFS0051)
- Added explicit Discord.Net package reference to BuildBot.Discord.Tests project
- Added unit tests for Name, EnterTypingState, and SendMessageAsync

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
…dMessageAsync test

ITextChannel extends IMessageChannel, so the channel substitute already
satisfies the IMessageChannel type required by message.Channel. The separate
messageChannel substitute was redundant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the 11-parameter Arg.Any<>() chain with ReturnsForAnyArgs so the
mock setup is resilient to future parameter additions to the Discord.Net
IMessageChannel.SendMessageAsync overload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ssageAsync

The mock setup used Arg.Any<>() for all arguments so a broken adapter
passing the wrong embed or text would still pass. Adding a Received(1)
call verifies the channel was called with text: string.Empty and the
correct embed value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The adapter always sends with text: string.Empty (embed-only), so
msg.CleanContent is always "" at runtime. The test stub documents this
is verifying pass-through behaviour, not a realistic value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hannelAdapter

Follows project convention (see BotService) of guarding all injected
constructor parameters with ?? throw new ArgumentNullException.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lAdapterTests

The comment described what the test does rather than a non-obvious why,
violating the code-quality rule. The production constraint (CleanContent
is always empty for embed-only messages) is retained as the sole comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… of navigating msg.Channel.Name

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
…est project

Discord.Net is available transitively via the ProjectReference to BuildBot.Discord.csproj,
so declaring it explicitly creates version-skew risk.

Prompt: Work on pull request #387 in funfair-tech/BuildBot.
@dnyw4l3n13 dnyw4l3n13 force-pushed the feature/374-discord-channel-adapter-testable branch from 22defff to 6726355 Compare July 4, 2026 15:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI-Work Work for an AI Agent buildbot-discord Changes in BuildBot.Discord project Tech Debt Technical debt

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Coverage: BuildBot.Discord — DiscordChannelAdapter needs integration-test coverage

2 participants