Skip to content

Commit 9cd84df

Browse files
HandyS11claude
andcommitted
test(coverage): cover hosted-service error branches + chat/commands/players hosted services
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 82425cc commit 9cd84df

6 files changed

Lines changed: 450 additions & 0 deletions

File tree

tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,30 @@ await h.Refresher.Received().RefreshAsync(
197197
await h.Service.StopAsync(default);
198198
}
199199

200+
[Fact]
201+
public async Task TriggeredLoop_survives_a_faulting_relay_and_StopAsync_completes_cleanly()
202+
{
203+
var h = Create();
204+
await h.Service.StartAsync(default);
205+
206+
var serverId = Guid.NewGuid();
207+
h.Store.When(s => s.GetAsync(10UL, serverId, 42UL, Arg.Any<CancellationToken>()))
208+
.Do(_ => throw new InvalidOperationException("relay boom"));
209+
210+
var deadline = DateTimeOffset.UtcNow.AddSeconds(20);
211+
while (DateTimeOffset.UtcNow < deadline
212+
&& !h.Store.ReceivedCalls().Any(c =>
213+
c.GetMethodInfo().Name == nameof(IAlarmStore.GetAsync)))
214+
{
215+
await h.Bus.PublishAsync(new SmartDeviceTriggeredEvent(10UL, serverId, 42UL, IsActive: true));
216+
await Task.Delay(20);
217+
}
218+
219+
// The relay threw; the loop swallowed it (LogTriggeredLoopFaulted) so StopAsync joins cleanly.
220+
await h.Store.Received().GetAsync(10UL, serverId, 42UL, Arg.Any<CancellationToken>());
221+
await h.Service.StopAsync(default);
222+
}
223+
200224
private sealed record Harness(
201225
AlarmsHostedService Service,
202226
InMemoryEventBus Bus,
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
using Discord.WebSocket;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using NSubstitute;
4+
using NSubstitute.ExceptionExtensions;
5+
using RustPlusBot.Abstractions.Events;
6+
using RustPlusBot.Abstractions.Time;
7+
using RustPlusBot.Features.Chat.Hosting;
8+
using RustPlusBot.Features.Chat.Inbound;
9+
using RustPlusBot.Features.Chat.Relaying;
10+
using RustPlusBot.Features.Chat.Webhooks;
11+
using RustPlusBot.Features.Connections.Listening;
12+
using RustPlusBot.Features.Workspace.Locating;
13+
14+
namespace RustPlusBot.Features.Chat.Tests.Hosting;
15+
16+
public sealed class ChatHostedServiceTests
17+
{
18+
private static (ChatHostedService Service, InMemoryEventBus Bus, ITeamChatWebhookPoster Poster,
19+
ITeamChatChannelLocator Locator)
20+
Build()
21+
{
22+
var clock = Substitute.For<IClock>();
23+
clock.UtcNow.Returns(DateTimeOffset.UnixEpoch);
24+
var dedup = new RelayDedupBuffer(clock);
25+
26+
var poster = Substitute.For<ITeamChatWebhookPoster>();
27+
var locator = Substitute.For<ITeamChatChannelLocator>();
28+
locator.GetChannelIdAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>())
29+
.Returns((ulong?)555UL);
30+
var relay = new TeamChatRelay(locator, poster, dedup);
31+
32+
var inboundLocator = Substitute.For<ITeamChatChannelLocator>();
33+
var sender = Substitute.For<ITeamChatSender>();
34+
// Processor not exercised by bus-side tests; stub scope factory is sufficient.
35+
var processor = ChatHostedServiceTestAccess.BuildProcessor(inboundLocator, sender, dedup);
36+
37+
var bus = new InMemoryEventBus();
38+
var client = new DiscordSocketClient();
39+
var service = new ChatHostedService(
40+
client,
41+
bus,
42+
relay,
43+
processor,
44+
NullLogger<ChatHostedService>.Instance);
45+
46+
return (service, bus, poster, locator);
47+
}
48+
49+
[Fact]
50+
public async Task TeamMessageReceivedEvent_routes_to_relay_and_posts_to_discord()
51+
{
52+
var (service, bus, poster, _) = Build();
53+
await service.StartAsync(default);
54+
55+
var deadline = DateTimeOffset.UtcNow.AddSeconds(20);
56+
while (DateTimeOffset.UtcNow < deadline
57+
&& !poster.ReceivedCalls().Any(c =>
58+
c.GetMethodInfo().Name == nameof(ITeamChatWebhookPoster.PostAsync)))
59+
{
60+
await bus.PublishAsync(
61+
new TeamMessageReceivedEvent(10UL, Guid.NewGuid(), 1UL, "Alice", "hello", FromActivePlayer: false));
62+
await Task.Delay(20);
63+
}
64+
65+
await poster.Received().PostAsync(555UL, "Alice", "hello", Arg.Any<CancellationToken>());
66+
67+
await service.StopAsync(default);
68+
}
69+
70+
[Fact]
71+
public async Task StartAsync_then_StopAsync_completes_cleanly()
72+
{
73+
var (service, _, _, _) = Build();
74+
await service.StartAsync(default);
75+
76+
var stop = service.StopAsync(default);
77+
await stop;
78+
79+
Assert.True(stop.IsCompletedSuccessfully);
80+
}
81+
82+
[Fact]
83+
public async Task RelayLoop_faults_on_poster_exception_but_StopAsync_completes_cleanly()
84+
{
85+
var (service, bus, poster, _) = Build();
86+
poster.PostAsync(Arg.Any<ulong>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
87+
.ThrowsAsync(new InvalidOperationException("simulated fault"));
88+
89+
await service.StartAsync(default);
90+
91+
// Publish until the relay has actually reached (and thrown from) the poster.
92+
var deadline = DateTimeOffset.UtcNow.AddSeconds(20);
93+
while (DateTimeOffset.UtcNow < deadline
94+
&& !poster.ReceivedCalls().Any(c =>
95+
c.GetMethodInfo().Name == nameof(ITeamChatWebhookPoster.PostAsync)))
96+
{
97+
await bus.PublishAsync(
98+
new TeamMessageReceivedEvent(10UL, Guid.NewGuid(), 1UL, "Bob", "boom", FromActivePlayer: false));
99+
await Task.Delay(20);
100+
}
101+
102+
// The loop swallowed the fault (LogRelayLoopFaulted); StopAsync must join cleanly, not rethrow.
103+
await poster.Received().PostAsync(Arg.Any<ulong>(), "Bob", "boom", Arg.Any<CancellationToken>());
104+
await service.StopAsync(default);
105+
}
106+
}
107+
108+
/// <summary>Provides internal access to build <see cref="TeamChatInboundProcessor"/> for tests.</summary>
109+
internal static class ChatHostedServiceTestAccess
110+
{
111+
internal static TeamChatInboundProcessor BuildProcessor(
112+
ITeamChatChannelLocator locator,
113+
ITeamChatSender sender,
114+
RelayDedupBuffer dedup)
115+
{
116+
// A NullScopeFactory is sufficient — processor is not exercised by the bus relay path under test.
117+
var scopeFactory = Substitute.For<Microsoft.Extensions.DependencyInjection.IServiceScopeFactory>();
118+
return new TeamChatInboundProcessor(locator, sender, dedup, scopeFactory);
119+
}
120+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using Microsoft.Extensions.Options;
4+
using NSubstitute;
5+
using RustPlusBot.Abstractions.Events;
6+
using RustPlusBot.Abstractions.Time;
7+
using RustPlusBot.Features.Commands.Dispatching;
8+
using RustPlusBot.Features.Commands.Hosting;
9+
using RustPlusBot.Features.Connections.Listening;
10+
using RustPlusBot.Persistence.Commands;
11+
using RustPlusBot.Persistence.Workspace;
12+
13+
namespace RustPlusBot.Features.Commands.Tests.Hosting;
14+
15+
public sealed class CommandsHostedServiceTests
16+
{
17+
private static Harness Create(bool prefixThrows = false)
18+
{
19+
var sender = Substitute.For<ITeamChatSender>();
20+
sender.SendAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
21+
.Returns(TeamChatSendResult.Sent);
22+
23+
var muteStore = Substitute.For<IMuteStore>();
24+
muteStore.GetMutedAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>()).Returns(false);
25+
if (prefixThrows)
26+
{
27+
muteStore.When(m => m.GetPrefixAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>()))
28+
.Do(_ => throw new InvalidOperationException("dispatch boom"));
29+
}
30+
else
31+
{
32+
muteStore.GetPrefixAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>()).Returns("!");
33+
}
34+
35+
var workspace = Substitute.For<IWorkspaceStore>();
36+
workspace.GetCultureAsync(Arg.Any<ulong>(), Arg.Any<CancellationToken>()).Returns("en");
37+
38+
var handler = new StubHandler("pop");
39+
var clock = Substitute.For<IClock>();
40+
clock.UtcNow.Returns(DateTimeOffset.UnixEpoch);
41+
var cooldown = new CommandCooldown(clock, Options.Create(new CommandOptions()));
42+
var dispatcher = new CommandDispatcher(
43+
[handler], cooldown, muteStore, workspace, sender, NullLogger<CommandDispatcher>.Instance);
44+
45+
var services = new ServiceCollection();
46+
services.AddScoped(_ => dispatcher);
47+
var provider = services.BuildServiceProvider();
48+
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
49+
50+
var bus = new InMemoryEventBus();
51+
var service = new CommandsHostedService(bus, scopeFactory, NullLogger<CommandsHostedService>.Instance);
52+
53+
return new Harness(service, bus, sender, handler, muteStore);
54+
}
55+
56+
[Fact]
57+
public async Task TeamMessageReceivedEvent_dispatches_the_command_and_relays_the_reply()
58+
{
59+
var h = Create();
60+
await h.Service.StartAsync(default);
61+
62+
var serverId = Guid.NewGuid();
63+
var deadline = DateTimeOffset.UtcNow.AddSeconds(20);
64+
while (DateTimeOffset.UtcNow < deadline
65+
&& !h.Sender.ReceivedCalls().Any(c =>
66+
c.GetMethodInfo().Name == nameof(ITeamChatSender.SendAsync)))
67+
{
68+
await h.Bus.PublishAsync(
69+
new TeamMessageReceivedEvent(10UL, serverId, 7UL, "alice", "!pop", FromActivePlayer: false));
70+
await Task.Delay(20);
71+
}
72+
73+
Assert.True(h.Handler.Calls >= 1);
74+
await h.Sender.Received().SendAsync(10UL, serverId, "reply", Arg.Any<CancellationToken>());
75+
76+
await h.Service.StopAsync(default);
77+
}
78+
79+
[Fact]
80+
public async Task StartAsync_then_StopAsync_completes_cleanly()
81+
{
82+
var h = Create();
83+
await h.Service.StartAsync(default);
84+
85+
var stop = h.Service.StopAsync(default);
86+
await stop;
87+
88+
Assert.True(stop.IsCompletedSuccessfully);
89+
}
90+
91+
[Fact]
92+
public async Task DispatchLoop_survives_a_faulting_dispatch_and_StopAsync_completes_cleanly()
93+
{
94+
var h = Create(prefixThrows: true);
95+
await h.Service.StartAsync(default);
96+
97+
var serverId = Guid.NewGuid();
98+
var deadline = DateTimeOffset.UtcNow.AddSeconds(20);
99+
while (DateTimeOffset.UtcNow < deadline
100+
&& !h.MuteStore.ReceivedCalls().Any(c =>
101+
c.GetMethodInfo().Name == nameof(IMuteStore.GetPrefixAsync)))
102+
{
103+
await h.Bus.PublishAsync(
104+
new TeamMessageReceivedEvent(10UL, serverId, 7UL, "alice", "!pop", FromActivePlayer: false));
105+
await Task.Delay(20);
106+
}
107+
108+
// The dispatch threw; the per-event catch swallowed it (LogDispatchFaulted) so the loop keeps running.
109+
await h.MuteStore.Received().GetPrefixAsync(10UL, serverId, Arg.Any<CancellationToken>());
110+
await h.Sender.DidNotReceive().SendAsync(
111+
Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
112+
await h.Service.StopAsync(default);
113+
}
114+
115+
private sealed record Harness(
116+
CommandsHostedService Service,
117+
InMemoryEventBus Bus,
118+
ITeamChatSender Sender,
119+
StubHandler Handler,
120+
IMuteStore MuteStore);
121+
122+
private sealed class StubHandler(string name) : ICommandHandler
123+
{
124+
public int Calls { get; private set; }
125+
126+
public string Name => name;
127+
128+
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
129+
{
130+
Calls++;
131+
return Task.FromResult<string?>("reply");
132+
}
133+
}
134+
}

0 commit comments

Comments
 (0)