-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathChatHostedServiceTests.cs
More file actions
132 lines (114 loc) · 5.53 KB
/
Copy pathChatHostedServiceTests.cs
File metadata and controls
132 lines (114 loc) · 5.53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
using Discord.WebSocket;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Chat.Hosting;
using RustPlusBot.Features.Chat.Inbound;
using RustPlusBot.Features.Chat.Relaying;
using RustPlusBot.Features.Chat.Webhooks;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Workspace.Locating;
using RustPlusBot.Persistence.Commands;
namespace RustPlusBot.Features.Chat.Tests.Hosting;
public sealed class ChatHostedServiceTests
{
private static (ChatHostedService Service, InMemoryEventBus Bus, ITeamChatWebhookPoster Poster,
ITeamChatChannelLocator Locator)
Build()
{
var clock = Substitute.For<IClock>();
clock.UtcNow.Returns(DateTimeOffset.UnixEpoch);
var dedup = new RelayDedupBuffer(clock);
var poster = Substitute.For<ITeamChatWebhookPoster>();
var locator = Substitute.For<ITeamChatChannelLocator>();
locator.GetChannelIdAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns((ulong?)555UL);
// The relay reads the scoped IMuteStore command prefix per message; stub a scope that provides it.
var muteStore = Substitute.For<IMuteStore>();
muteStore.GetPrefixAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>()).Returns("!");
var relayScopeFactory = Substitute.For<IServiceScopeFactory>();
var relayScope = Substitute.For<IServiceScope>();
var relayScopeProvider = Substitute.For<IServiceProvider>();
relayScopeProvider.GetService(typeof(IMuteStore)).Returns(muteStore);
relayScope.ServiceProvider.Returns(relayScopeProvider);
relayScopeFactory.CreateScope().Returns(relayScope);
var relay = new TeamChatRelay(locator, poster, dedup, relayScopeFactory);
var inboundLocator = Substitute.For<ITeamChatChannelLocator>();
var sender = Substitute.For<ITeamChatSender>();
// Processor not exercised by bus-side tests; stub scope factory is sufficient.
var processor = ChatHostedServiceTestAccess.BuildProcessor(inboundLocator, sender, dedup);
var bus = new InMemoryEventBus();
var client = new DiscordSocketClient();
var service = new ChatHostedService(
client,
bus,
relay,
processor,
NullLogger<ChatHostedService>.Instance);
return (service, bus, poster, locator);
}
[Fact]
public async Task TeamMessageReceivedEvent_routes_to_relay_and_posts_to_discord()
{
var (service, bus, poster, _) = Build();
await service.StartAsync(default);
var deadline = DateTimeOffset.UtcNow.AddSeconds(20);
while (DateTimeOffset.UtcNow < deadline
&& !poster.ReceivedCalls().Any(c =>
c.GetMethodInfo().Name == nameof(ITeamChatWebhookPoster.PostAsync)))
{
await bus.PublishAsync(
new TeamMessageReceivedEvent(10UL, Guid.NewGuid(), 1UL, "Alice", "hello", FromActivePlayer: false));
await Task.Delay(20);
}
await poster.Received().PostAsync(555UL, "Alice", "hello", Arg.Any<CancellationToken>());
await service.StopAsync(default);
}
[Fact]
public async Task StartAsync_then_StopAsync_completes_cleanly()
{
var (service, _, _, _) = Build();
await service.StartAsync(default);
var stop = service.StopAsync(default);
await stop;
Assert.True(stop.IsCompletedSuccessfully);
}
[Fact]
public async Task RelayLoop_faults_on_poster_exception_but_StopAsync_completes_cleanly()
{
var (service, bus, poster, _) = Build();
poster.PostAsync(Arg.Any<ulong>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("simulated fault"));
await service.StartAsync(default);
// Publish until the relay has actually reached (and thrown from) the poster.
var deadline = DateTimeOffset.UtcNow.AddSeconds(20);
while (DateTimeOffset.UtcNow < deadline
&& !poster.ReceivedCalls().Any(c =>
c.GetMethodInfo().Name == nameof(ITeamChatWebhookPoster.PostAsync)))
{
await bus.PublishAsync(
new TeamMessageReceivedEvent(10UL, Guid.NewGuid(), 1UL, "Bob", "boom", FromActivePlayer: false));
await Task.Delay(20);
}
// The relay threw, causing the loop to fault and complete (LogRelayLoopFaulted). StopAsync joins the
// faulted task cleanly — no rethrow. This is crash-isolation, not per-event resilience.
await poster.Received().PostAsync(Arg.Any<ulong>(), "Bob", "boom", Arg.Any<CancellationToken>());
await service.StopAsync(default);
}
}
/// <summary>Provides internal access to build <see cref="TeamChatInboundProcessor"/> for tests.</summary>
internal static class ChatHostedServiceTestAccess
{
internal static TeamChatInboundProcessor BuildProcessor(
ITeamChatChannelLocator locator,
ITeamChatSender sender,
RelayDedupBuffer dedup)
{
// A NullScopeFactory is sufficient — processor is not exercised by the bus relay path under test.
var scopeFactory = Substitute.For<Microsoft.Extensions.DependencyInjection.IServiceScopeFactory>();
return new TeamChatInboundProcessor(locator, sender, dedup, scopeFactory);
}
}