diff --git a/CHANGELOG.md b/CHANGELOG.md
index d39e792e..780e2628 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/BuildBot.Discord.Tests/BuildBot.Discord.Tests.csproj b/src/BuildBot.Discord.Tests/BuildBot.Discord.Tests.csproj
index a05fb59c..b687ed47 100644
--- a/src/BuildBot.Discord.Tests/BuildBot.Discord.Tests.csproj
+++ b/src/BuildBot.Discord.Tests/BuildBot.Discord.Tests.csproj
@@ -70,4 +70,4 @@
-
\ No newline at end of file
+
diff --git a/src/BuildBot.Discord.Tests/Services/DiscordChannelAdapterTests.cs b/src/BuildBot.Discord.Tests/Services/DiscordChannelAdapterTests.cs
new file mode 100644
index 00000000..34ed239f
--- /dev/null
+++ b/src/BuildBot.Discord.Tests/Services/DiscordChannelAdapterTests.cs
@@ -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();
+ 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();
+ IDisposable typingState = GetSubstitute();
+ channel.EnterTypingState(Arg.Any()).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();
+ IUserMessage message = GetSubstitute();
+
+ channel.Name.Returns("sent-channel");
+
+ // In production CleanContent is always "" because the adapter sends text: string.Empty (embed-only).
+ message.CleanContent.Returns("clean content");
+
+ 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);
+
+ await channel
+ .Received(1)
+ .SendMessageAsync(
+ text: string.Empty,
+ isTTS: Arg.Any(),
+ embed: embed,
+ options: Arg.Any(),
+ allowedMentions: Arg.Any(),
+ messageReference: Arg.Any(),
+ components: Arg.Any(),
+ stickers: Arg.Any(),
+ embeds: Arg.Any