Skip to content

Commit 759614a

Browse files
committed
feat: add GuildPurgeService (per-guild data purge)
1 parent ce0e14a commit 759614a

4 files changed

Lines changed: 141 additions & 0 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using RustPlusBot.Persistence;
3+
using RustPlusBot.Persistence.Servers;
4+
5+
namespace RustPlusBot.Features.Workspace.Teardown;
6+
7+
/// <summary>Purges a guild: tears down provisioned channels, then deletes its domain rows.</summary>
8+
/// <param name="context">The bot database context.</param>
9+
/// <param name="servers">Server management (RemoveAsync cascades all per-server rows).</param>
10+
/// <param name="teardown">Removes provisioned Discord channels/categories/messages.</param>
11+
internal sealed class GuildPurgeService(
12+
BotDbContext context,
13+
IServerService servers,
14+
IWorkspaceTeardownService teardown) : IGuildPurgeService
15+
{
16+
/// <inheritdoc />
17+
public async Task PurgeGuildAsync(ulong guildId, CancellationToken cancellationToken = default)
18+
{
19+
// 1) Delete provisioned Discord channels/categories/messages (Discord side + records).
20+
await teardown.ResetGuildAsync(guildId, cancellationToken).ConfigureAwait(false);
21+
22+
// 2) Remove each server; the RustServer FK cascade clears its per-server rows
23+
// (connection state, command/map settings, switches, alarms, storage monitors, credentials).
24+
var known = await servers.ListAsync(guildId, cancellationToken).ConfigureAwait(false);
25+
foreach (var server in known)
26+
{
27+
await servers.RemoveAsync(guildId, server.Id, cancellationToken).ConfigureAwait(false);
28+
}
29+
30+
// 3) Delete guild-keyed rows that have no cascade FK to RustServer.
31+
await context.EventSubscriptions.Where(e => e.GuildId == guildId)
32+
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
33+
await context.PairedEntities.Where(p => p.GuildId == guildId)
34+
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
35+
await context.GuildSettings.Where(g => g.GuildId == guildId)
36+
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
37+
}
38+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace RustPlusBot.Features.Workspace.Teardown;
2+
3+
/// <summary>Deletes all of a guild's data: provisioned channels plus its domain rows.</summary>
4+
internal interface IGuildPurgeService
5+
{
6+
/// <summary>Purges one guild back to a just-joined state (channels + servers + guild-scoped rows).</summary>
7+
/// <param name="guildId">The guild to purge.</param>
8+
/// <param name="cancellationToken">A cancellation token.</param>
9+
/// <returns>A task that completes when the guild's data has been removed.</returns>
10+
Task PurgeGuildAsync(ulong guildId, CancellationToken cancellationToken = default);
11+
}

src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services)
5151
services.AddScoped<WorkspaceTeardownService>();
5252
services.AddScoped<IWorkspaceTeardownService>(sp => sp.GetRequiredService<WorkspaceTeardownService>());
5353
services.AddScoped<IServerWorkspaceRemover>(sp => sp.GetRequiredService<WorkspaceTeardownService>());
54+
services.AddScoped<IGuildPurgeService, GuildPurgeService>();
5455

5556
// Options (Host binds the "Workspace" section; default = danger commands off).
5657
services.AddOptions<WorkspaceOptions>();
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
using Microsoft.Data.Sqlite;
2+
using Microsoft.EntityFrameworkCore;
3+
using NSubstitute;
4+
using RustPlusBot.Domain.Connections;
5+
using RustPlusBot.Domain.Entities;
6+
using RustPlusBot.Domain.Events;
7+
using RustPlusBot.Domain.Guilds;
8+
using RustPlusBot.Domain.Servers;
9+
using RustPlusBot.Domain.Switches;
10+
using RustPlusBot.Features.Workspace.Teardown;
11+
using RustPlusBot.Persistence;
12+
using RustPlusBot.Persistence.Servers;
13+
14+
namespace RustPlusBot.Features.Workspace.Tests.Teardown;
15+
16+
public sealed class GuildPurgeServiceTests
17+
{
18+
private static BotDbContext NewContext(SqliteConnection connection)
19+
{
20+
var options = new DbContextOptionsBuilder<BotDbContext>().UseSqlite(connection).Options;
21+
var context = new BotDbContext(options);
22+
context.Database.Migrate();
23+
return context;
24+
}
25+
26+
[Fact]
27+
public async Task PurgeGuild_RemovesTargetGuildRows_AndLeavesOtherGuildIntact()
28+
{
29+
var connection = new SqliteConnection("DataSource=:memory:");
30+
await connection.OpenAsync();
31+
await using var _ = connection;
32+
await using var context = NewContext(connection);
33+
34+
var serverA = new RustServer
35+
{
36+
GuildId = 1, Name = "A", Ip = "a", Port = 1
37+
};
38+
var serverB = new RustServer
39+
{
40+
GuildId = 2, Name = "B", Ip = "b", Port = 2
41+
};
42+
context.RustServers.AddRange(serverA, serverB);
43+
context.SmartSwitches.Add(new SmartSwitch
44+
{
45+
GuildId = 1, ServerId = serverA.Id, EntityId = 10, Name = "sw"
46+
});
47+
context.ConnectionStates.Add(new ConnectionState
48+
{
49+
RustServerId = serverA.Id, GuildId = 1, Status = ConnectionStatus.Connected
50+
});
51+
context.EventSubscriptions.Add(new EventSubscription
52+
{
53+
GuildId = 1, RustServerId = serverA.Id, EventKey = "cargo"
54+
});
55+
context.EventSubscriptions.Add(new EventSubscription
56+
{
57+
GuildId = 2, RustServerId = serverB.Id, EventKey = "cargo"
58+
});
59+
context.PairedEntities.Add(new PairedEntity
60+
{
61+
GuildId = 1, RustServerId = serverA.Id, EntityId = 5, Name = "dev"
62+
});
63+
context.GuildSettings.Add(new GuildSettings
64+
{
65+
GuildId = 1, Culture = "en"
66+
});
67+
context.GuildSettings.Add(new GuildSettings
68+
{
69+
GuildId = 2, Culture = "fr"
70+
});
71+
await context.SaveChangesAsync();
72+
73+
var teardown = Substitute.For<IWorkspaceTeardownService>();
74+
var service = new GuildPurgeService(context, new ServerService(context), teardown);
75+
76+
await service.PurgeGuildAsync(1);
77+
78+
await teardown.Received(1).ResetGuildAsync(1, Arg.Any<CancellationToken>());
79+
Assert.Empty(await context.RustServers.Where(s => s.GuildId == 1).ToListAsync());
80+
Assert.Empty(await context.SmartSwitches.ToListAsync());
81+
Assert.Empty(await context.ConnectionStates.ToListAsync());
82+
Assert.Empty(await context.EventSubscriptions.Where(e => e.GuildId == 1).ToListAsync());
83+
Assert.Empty(await context.PairedEntities.Where(p => p.GuildId == 1).ToListAsync());
84+
Assert.Empty(await context.GuildSettings.Where(g => g.GuildId == 1).ToListAsync());
85+
86+
// Guild 2 untouched.
87+
Assert.Single(await context.RustServers.Where(s => s.GuildId == 2).ToListAsync());
88+
Assert.Single(await context.EventSubscriptions.Where(e => e.GuildId == 2).ToListAsync());
89+
Assert.Single(await context.GuildSettings.Where(g => g.GuildId == 2).ToListAsync());
90+
}
91+
}

0 commit comments

Comments
 (0)