Skip to content

Commit 3973353

Browse files
HandyS11claude
andcommitted
feat(connections): server removal orchestrator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f52914d commit 3973353

6 files changed

Lines changed: 93 additions & 0 deletions

File tree

src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using RustPlusBot.Discord;
33
using RustPlusBot.Features.Connections.Hosting;
44
using RustPlusBot.Features.Connections.Listening;
5+
using RustPlusBot.Features.Connections.Removal;
56
using RustPlusBot.Features.Connections.Supervisor;
67

78
namespace RustPlusBot.Features.Connections;
@@ -18,6 +19,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services
1819

1920
services.AddSingleton<IRustSocketSource, RustPlusSocketSource>();
2021
services.AddSingleton<IConnectionSupervisor, ConnectionSupervisor>();
22+
services.AddScoped<IServerRemovalService, ServerRemovalService>();
2123

2224
// Contribute this assembly's interaction modules to the Discord layer.
2325
services.AddSingleton(new InteractionModuleAssembly(typeof(ConnectionServiceCollectionExtensions).Assembly));
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace RustPlusBot.Features.Connections.Removal;
2+
3+
/// <summary>Orchestrates removing a server: stop the socket, delete the row (cascades), tear down the workspace.</summary>
4+
internal interface IServerRemovalService
5+
{
6+
/// <summary>Removes a server end-to-end. Returns true if a server row was deleted.</summary>
7+
/// <param name="guildId">The owning guild snowflake.</param>
8+
/// <param name="serverId">The server to remove.</param>
9+
/// <param name="cancellationToken">A cancellation token.</param>
10+
/// <returns>True if a matching server row was removed.</returns>
11+
Task<bool> RemoveServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default);
12+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using RustPlusBot.Features.Connections.Supervisor;
2+
using RustPlusBot.Features.Workspace.Teardown;
3+
using RustPlusBot.Persistence.Servers;
4+
5+
namespace RustPlusBot.Features.Connections.Removal;
6+
7+
/// <summary>Default <see cref="IServerRemovalService"/>. Order matters: stop the socket before deleting the row,
8+
/// so no late status write re-inserts a connection-state row against a deleted server (FK violation).</summary>
9+
/// <param name="supervisor">Stops the live socket.</param>
10+
/// <param name="servers">Deletes the RustServer (cascades credentials + connection state).</param>
11+
/// <param name="workspace">Tears down the server's Discord category/channels/records.</param>
12+
internal sealed class ServerRemovalService(
13+
IConnectionSupervisor supervisor,
14+
IServerService servers,
15+
IServerWorkspaceRemover workspace) : IServerRemovalService
16+
{
17+
/// <inheritdoc />
18+
public async Task<bool> RemoveServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default)
19+
{
20+
await supervisor.StopAsync(guildId, serverId).ConfigureAwait(false);
21+
var removed = await servers.RemoveAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
22+
await workspace.RemoveServerAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
23+
return removed;
24+
}
25+
}

tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
using RustPlusBot.Abstractions.Time;
77
using RustPlusBot.Discord.Notifications;
88
using RustPlusBot.Features.Connections;
9+
using RustPlusBot.Features.Connections.Removal;
910
using RustPlusBot.Features.Connections.Supervisor;
11+
using RustPlusBot.Features.Workspace.Teardown;
1012
using RustPlusBot.Persistence;
1113
using RustPlusBot.Persistence.Connections;
1214

@@ -26,6 +28,7 @@ public async Task Services_Resolve()
2628
services.AddLogging();
2729
services.AddBotPersistence("DataSource=:memory:");
2830
services.AddOptions<ConnectionOptions>();
31+
services.AddSingleton(Substitute.For<IServerWorkspaceRemover>());
2932
services.AddConnections();
3033

3134
await using var provider = services.BuildServiceProvider(new ServiceProviderOptions
@@ -36,5 +39,6 @@ public async Task Services_Resolve()
3639
Assert.NotNull(provider.GetRequiredService<IConnectionSupervisor>());
3740
await using var scope = provider.CreateAsyncScope();
3841
Assert.NotNull(scope.ServiceProvider.GetRequiredService<IConnectionStore>());
42+
Assert.NotNull(scope.ServiceProvider.GetRequiredService<IServerRemovalService>());
3943
}
4044
}

tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
<ItemGroup>
1818
<ProjectReference Include="..\..\src\RustPlusBot.Features.Connections\RustPlusBot.Features.Connections.csproj" />
19+
<ProjectReference Include="..\..\src\RustPlusBot.Features.Workspace\RustPlusBot.Features.Workspace.csproj" />
1920
</ItemGroup>
2021

2122
</Project>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using NSubstitute;
2+
using RustPlusBot.Features.Connections.Removal;
3+
using RustPlusBot.Features.Connections.Supervisor;
4+
using RustPlusBot.Features.Workspace.Teardown;
5+
using RustPlusBot.Persistence.Servers;
6+
7+
namespace RustPlusBot.Features.Connections.Tests;
8+
9+
public sealed class ServerRemovalServiceTests
10+
{
11+
[Fact]
12+
public async Task RemoveServer_Stops_Deletes_TearsDown_InOrder()
13+
{
14+
var serverId = Guid.NewGuid();
15+
var supervisor = Substitute.For<IConnectionSupervisor>();
16+
var servers = Substitute.For<IServerService>();
17+
servers.RemoveAsync(10UL, serverId, Arg.Any<CancellationToken>()).Returns(true);
18+
var workspace = Substitute.For<IServerWorkspaceRemover>();
19+
var sut = new ServerRemovalService(supervisor, servers, workspace);
20+
21+
var removed = await sut.RemoveServerAsync(10UL, serverId);
22+
23+
Assert.True(removed);
24+
#pragma warning disable VSTHRD110 // Received.InOrder requires unawaited calls inside its synchronous ordering lambda — this is the NSubstitute-prescribed pattern.
25+
Received.InOrder(() =>
26+
{
27+
supervisor.StopAsync(10UL, serverId);
28+
servers.RemoveAsync(10UL, serverId, Arg.Any<CancellationToken>());
29+
workspace.RemoveServerAsync(10UL, serverId, Arg.Any<CancellationToken>());
30+
});
31+
#pragma warning restore VSTHRD110
32+
}
33+
34+
[Fact]
35+
public async Task RemoveServer_WhenServerAbsent_ReturnsFalse_StillTearsDown()
36+
{
37+
var serverId = Guid.NewGuid();
38+
var supervisor = Substitute.For<IConnectionSupervisor>();
39+
var servers = Substitute.For<IServerService>();
40+
servers.RemoveAsync(10UL, serverId, Arg.Any<CancellationToken>()).Returns(false);
41+
var workspace = Substitute.For<IServerWorkspaceRemover>();
42+
var sut = new ServerRemovalService(supervisor, servers, workspace);
43+
44+
var removed = await sut.RemoveServerAsync(10UL, serverId);
45+
46+
Assert.False(removed);
47+
await workspace.Received(1).RemoveServerAsync(10UL, serverId, Arg.Any<CancellationToken>());
48+
}
49+
}

0 commit comments

Comments
 (0)