diff --git a/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs b/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs index 09be754b..b6b09d3b 100644 --- a/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs +++ b/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs @@ -25,4 +25,28 @@ Task UpsertFromPairingAsync( /// A cancellation token. /// The number of stored credentials for that (guild, server). Task CountForServerAsync(ulong guildId, Guid rustServerId, CancellationToken cancellationToken = default); + + /// + /// Removes every player-credential the owner holds across all servers in the guild (full account + /// removal). Returns the distinct server ids whose pool was affected, so callers can re-evaluate + /// those connections. + /// + /// Owning Discord guild snowflake. + /// The Discord user whose credentials are removed. + /// A cancellation token. + /// The distinct server ids whose pool changed. + Task> RemoveForOwnerAsync( + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default); + + /// Lists the distinct server ids the owner currently holds a credential for in the guild. + /// Owning Discord guild snowflake. + /// The Discord user. + /// A cancellation token. + /// The distinct server ids the owner has a credential for. + Task> ListServerIdsForOwnerAsync( + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default); } diff --git a/src/RustPlusBot.Abstractions/Events/ServerCredentialsChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/ServerCredentialsChangedEvent.cs new file mode 100644 index 00000000..d0d3d738 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/ServerCredentialsChangedEvent.cs @@ -0,0 +1,9 @@ +namespace RustPlusBot.Abstractions.Events; + +/// +/// Raised when a server's credential pool changed out-of-band (e.g. a user disconnected their account), +/// so the live connection should be re-evaluated and the #info view refreshed. +/// +/// The owning Discord guild snowflake. +/// The affected server. +public sealed record ServerCredentialsChangedEvent(ulong GuildId, Guid ServerId); diff --git a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs index b82a2093..bd19e028 100644 --- a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using RustPlusBot.Discord; using RustPlusBot.Features.Connections.Hosting; using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Connections.Removal; using RustPlusBot.Features.Connections.Supervisor; namespace RustPlusBot.Features.Connections; @@ -18,6 +19,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services services.AddSingleton(); services.AddSingleton(); + services.AddScoped(); // Contribute this assembly's interaction modules to the Discord layer. services.AddSingleton(new InteractionModuleAssembly(typeof(ConnectionServiceCollectionExtensions).Assembly)); diff --git a/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs b/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs index 8c4fcefb..e4f2c3b9 100644 --- a/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs +++ b/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs @@ -50,13 +50,34 @@ public async Task StopAsync(CancellationToken cancellationToken) private async Task RunAsync(CancellationToken cancellationToken) { - // Subscription is registered when this loop first awaits the bus, after StartAllAsync completes. + // Subscriptions register when each loop first awaits the bus, after StartAllAsync completes. // The in-process bus does not replay, so events published before this point are not delivered. Safe: - // the server-registered event is only raised by the FCM pairing flow at runtime, long after startup. + // both events are only raised at runtime, long after startup. try { await supervisor.StartAllAsync(cancellationToken).ConfigureAwait(false); + await Task.WhenAll( + ConsumeRegisteredAsync(cancellationToken), + ConsumeCredentialsChangedAsync(cancellationToken)) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch is intentional: a faulting consumer must not crash the host. + catch (Exception ex) + { + LogLoopFaulted(logger, ex); + } +#pragma warning restore CA1031 + } + + private async Task ConsumeRegisteredAsync(CancellationToken cancellationToken) + { + try + { await foreach (var registered in eventBus.SubscribeAsync(cancellationToken) .ConfigureAwait(false)) { @@ -68,7 +89,30 @@ await supervisor.EnsureConnectionAsync(registered.GuildId, registered.ServerId, { // Shutting down. } -#pragma warning disable CA1031 // Broad catch is intentional: a faulting consumer must not crash the host. +#pragma warning disable CA1031 // Broad catch is intentional: one faulting consumer must not stop the other or crash the host. + catch (Exception ex) + { + LogLoopFaulted(logger, ex); + } +#pragma warning restore CA1031 + } + + private async Task ConsumeCredentialsChangedAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var changed in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await supervisor.EnsureConnectionAsync(changed.GuildId, changed.ServerId, cancellationToken) + .ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch is intentional: one faulting consumer must not stop the other or crash the host. catch (Exception ex) { LogLoopFaulted(logger, ex); diff --git a/src/RustPlusBot.Features.Connections/Modules/ServerRemovalModule.cs b/src/RustPlusBot.Features.Connections/Modules/ServerRemovalModule.cs new file mode 100644 index 00000000..c89131b8 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Modules/ServerRemovalModule.cs @@ -0,0 +1,79 @@ +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Connections.Removal; +using RustPlusBot.Features.Workspace; + +namespace RustPlusBot.Features.Connections.Modules; + +/// Handles the #info "Remove server" button and its confirmation (ManageGuild). +/// Creates a short-lived DI scope per interaction. +public sealed class ServerRemovalModule(IServiceScopeFactory scopeFactory) + : InteractionModuleBase +{ + private const string RemoveConfirmPrefix = "connections:server:remove:confirm:"; + private const string RemoveCancelId = "connections:server:remove:cancel"; + + /// Opens an ephemeral confirmation for removing the server. + /// The server id captured from the custom id. + [ComponentInteraction(WorkspaceComponentIds.ServerInfoRemovePrefix + "*")] + [RequireUserPermission(GuildPermission.ManageGuild)] + public async Task RemovePromptAsync(string serverIdRaw) + { + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + if (!Guid.TryParse(serverIdRaw, out var serverId)) + { + await RespondAsync("That selection wasn't valid.", ephemeral: true).ConfigureAwait(false); + return; + } + + var components = new ComponentBuilder() + .WithButton("Remove server", $"{RemoveConfirmPrefix}{serverId}", ButtonStyle.Danger) + .WithButton("Cancel", RemoveCancelId, ButtonStyle.Secondary) + .Build(); + await RespondAsync( + "This permanently deletes the category, all channels, and stored credentials for this server. Continue?", + ephemeral: true, components: components) + .ConfigureAwait(false); + } + + /// Removes the server after confirmation. + /// The server id captured from the custom id. + [ComponentInteraction(RemoveConfirmPrefix + "*")] + [RequireUserPermission(GuildPermission.ManageGuild)] + public async Task RemoveConfirmAsync(string serverIdRaw) + { + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + if (!Guid.TryParse(serverIdRaw, out var serverId)) + { + await RespondAsync("That selection wasn't valid.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var removal = scope.ServiceProvider.GetRequiredService(); + var removed = await removal.RemoveServerAsync(Context.Guild.Id, serverId).ConfigureAwait(false); + await FollowupAsync(removed ? "Server removed." : "That server no longer exists.", ephemeral: true) + .ConfigureAwait(false); + } + } + + /// Cancels the removal. + [ComponentInteraction(RemoveCancelId)] + public async Task RemoveCancelAsync() => + await RespondAsync("Cancelled.", ephemeral: true).ConfigureAwait(false); +} diff --git a/src/RustPlusBot.Features.Connections/Removal/IServerRemovalService.cs b/src/RustPlusBot.Features.Connections/Removal/IServerRemovalService.cs new file mode 100644 index 00000000..c9d6a712 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Removal/IServerRemovalService.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Features.Connections.Removal; + +/// Orchestrates removing a server: stop the socket, delete the row (cascades), tear down the workspace. +internal interface IServerRemovalService +{ + /// Removes a server end-to-end. Returns true if a server row was deleted. + /// The owning guild snowflake. + /// The server to remove. + /// A cancellation token. + /// True if a matching server row was removed. + Task RemoveServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Features.Connections/Removal/ServerRemovalService.cs b/src/RustPlusBot.Features.Connections/Removal/ServerRemovalService.cs new file mode 100644 index 00000000..fac9493d --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Removal/ServerRemovalService.cs @@ -0,0 +1,27 @@ +using RustPlusBot.Features.Connections.Supervisor; +using RustPlusBot.Features.Workspace.Teardown; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Connections.Removal; + +/// Default . Order matters: stop the socket before deleting the row, +/// so no late status write re-inserts a connection-state row against a deleted server (FK violation). +/// Stops the live socket. +/// Deletes the RustServer (cascades credentials + connection state). +/// Tears down the server's Discord category/channels/records. +internal sealed class ServerRemovalService( + IConnectionSupervisor supervisor, + IServerService servers, + IServerWorkspaceRemover workspace) : IServerRemovalService +{ + /// + public async Task RemoveServerAsync(ulong guildId, + Guid serverId, + CancellationToken cancellationToken = default) + { + await supervisor.StopAsync(guildId, serverId).ConfigureAwait(false); + var removed = await servers.RemoveAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + await workspace.RemoveServerAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + return removed; + } +} diff --git a/src/RustPlusBot.Features.Pairing/Accounts/AccountDisconnectService.cs b/src/RustPlusBot.Features.Pairing/Accounts/AccountDisconnectService.cs new file mode 100644 index 00000000..c28f4f82 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Accounts/AccountDisconnectService.cs @@ -0,0 +1,73 @@ +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Features.Pairing.Supervisor; +using RustPlusBot.Persistence.Credentials; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Pairing.Accounts; + +/// Default . +/// Stops the user's FCM listener. +/// The FCM registration store. +/// The player-credential pool store. +/// Resolves server names for the preview. +/// Publishes ServerCredentialsChangedEvent per affected server. +internal sealed class AccountDisconnectService( + IPairingSupervisor supervisor, + IFcmRegistrationStore registrations, + ICredentialStore credentials, + IServerService servers, + IEventBus eventBus) : IAccountDisconnectService +{ + /// + public async Task PreviewAsync( + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default) + { + var registration = await registrations.GetAsync(guildId, ownerUserId, cancellationToken).ConfigureAwait(false); + var serverIds = await credentials.ListServerIdsForOwnerAsync(guildId, ownerUserId, cancellationToken) + .ConfigureAwait(false); + + var names = new List(serverIds.Count); + foreach (var serverId in serverIds) + { + var server = await servers.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (server is not null) + { + names.Add(server.Name); + } + } + + var isConnected = (registration is not null && registration.Status != FcmRegistrationStatus.Disabled) + || serverIds.Count > 0; + return new AccountDisconnectPreview(isConnected, names); + } + + /// + public async Task DisconnectAsync( + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default) + { + await supervisor.StopListenerAsync(guildId, ownerUserId).ConfigureAwait(false); + + var registration = await registrations.GetAsync(guildId, ownerUserId, cancellationToken).ConfigureAwait(false); + if (registration is not null) + { + await registrations.SetStatusAsync(registration.Id, FcmRegistrationStatus.Disabled, cancellationToken) + .ConfigureAwait(false); + } + + var affected = await credentials.RemoveForOwnerAsync(guildId, ownerUserId, cancellationToken) + .ConfigureAwait(false); + foreach (var serverId in affected) + { + await eventBus.PublishAsync(new ServerCredentialsChangedEvent(guildId, serverId), cancellationToken) + .ConfigureAwait(false); + } + + return affected.Count; + } +} diff --git a/src/RustPlusBot.Features.Pairing/Accounts/IAccountDisconnectService.cs b/src/RustPlusBot.Features.Pairing/Accounts/IAccountDisconnectService.cs new file mode 100644 index 00000000..93492760 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Accounts/IAccountDisconnectService.cs @@ -0,0 +1,33 @@ +namespace RustPlusBot.Features.Pairing.Accounts; + +/// Full account disconnect: stop the listener, disable the registration, drop the user's pool credentials. +internal interface IAccountDisconnectService +{ + /// Reads what a disconnect would affect, for the confirmation dialog. + /// The owning guild snowflake. + /// The Discord user. + /// A cancellation token. + /// Whether the user is connected and the names of servers they would be removed from. + Task PreviewAsync( + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default); + + /// + /// Stops the user's FCM listener, marks their registration Disabled, removes all their pool credentials + /// in the guild, and publishes a ServerCredentialsChangedEvent per affected server. + /// + /// The owning guild snowflake. + /// The Discord user. + /// A cancellation token. + /// The number of servers the user was removed from. + Task DisconnectAsync( + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default); +} + +/// What an account disconnect would affect. +/// True if the user has a non-Disabled registration or any pool credential. +/// Names of servers the user holds a credential for. +internal sealed record AccountDisconnectPreview(bool IsConnected, IReadOnlyList AffectedServerNames); diff --git a/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs b/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs index 03ace00e..9033c62f 100644 --- a/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs +++ b/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs @@ -1,5 +1,7 @@ +using Discord; using Discord.Interactions; using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Pairing.Accounts; using RustPlusBot.Features.Pairing.Listening; using RustPlusBot.Features.Pairing.Supervisor; using RustPlusBot.Features.Pairing.Validation; @@ -13,6 +15,9 @@ namespace RustPlusBot.Features.Pairing.Modules; public sealed class CredentialModule(IServiceScopeFactory scopeFactory) : InteractionModuleBase { + private const string DisconnectConfirmId = "pairing:account:disconnect:confirm"; + private const string DisconnectCancelId = "pairing:account:disconnect:cancel"; + /// Opens the credentials modal when the #setup button is clicked. [ComponentInteraction(WorkspaceComponentIds.ConnectAccount)] public async Task OpenAsync() @@ -68,4 +73,68 @@ await FollowupAsync( await FollowupAsync(message, ephemeral: true).ConfigureAwait(false); } } + + /// Opens an ephemeral confirmation listing the servers a disconnect would affect. + [ComponentInteraction(WorkspaceComponentIds.DisconnectAccount)] + public async Task DisconnectPromptAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var disconnect = scope.ServiceProvider.GetRequiredService(); + var preview = await disconnect.PreviewAsync(Context.Guild.Id, Context.User.Id).ConfigureAwait(false); + if (!preview.IsConnected) + { + await FollowupAsync("You're not connected.", ephemeral: true).ConfigureAwait(false); + return; + } + + var servers = preview.AffectedServerNames.Count > 0 + ? string.Join(", ", preview.AffectedServerNames) + : "no servers yet"; + var components = new ComponentBuilder() + .WithButton("Disconnect", DisconnectConfirmId, ButtonStyle.Danger) + .WithButton("Cancel", DisconnectCancelId, ButtonStyle.Secondary) + .Build(); + await FollowupAsync( + $"This disconnects your account and removes your credentials from: {servers}. Continue?", + ephemeral: true, components: components) + .ConfigureAwait(false); + } + } + + /// Performs the disconnect after confirmation. + [ComponentInteraction(DisconnectConfirmId)] + public async Task DisconnectConfirmAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var disconnect = scope.ServiceProvider.GetRequiredService(); + var count = await disconnect.DisconnectAsync(Context.Guild.Id, Context.User.Id).ConfigureAwait(false); + await FollowupAsync($"Account disconnected. Removed from {count} server(s).", ephemeral: true) + .ConfigureAwait(false); + } + } + + /// Cancels the disconnect. + [ComponentInteraction(DisconnectCancelId)] + public async Task DisconnectCancelAsync() => + await RespondAsync("Cancelled.", ephemeral: true).ConfigureAwait(false); } diff --git a/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs index ba0da9e3..409d8aae 100644 --- a/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Discord; +using RustPlusBot.Features.Pairing.Accounts; using RustPlusBot.Features.Pairing.Hosting; using RustPlusBot.Features.Pairing.Listening; using RustPlusBot.Features.Pairing.Notifications; @@ -22,6 +23,7 @@ public static IServiceCollection AddPairing(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddScoped(); // Contribute this assembly's interaction modules to the Discord layer. services.AddSingleton(new InteractionModuleAssembly(typeof(PairingServiceCollectionExtensions).Assembly)); diff --git a/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj b/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj index b06ad5f3..9bb67764 100644 --- a/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj +++ b/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj @@ -2,6 +2,7 @@ + diff --git a/src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs b/src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs index 7380754b..73c80764 100644 --- a/src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs +++ b/src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs @@ -22,6 +22,12 @@ Task EnsureListenerAsync( /// A cancellation token. Task StartAllActiveAsync(CancellationToken cancellationToken = default); + /// Stops the listener for (guild, owner), if running. Idempotent. + /// The owning guild snowflake. + /// The Discord user. + /// A task that completes once the listener has stopped. + Task StopListenerAsync(ulong guildId, ulong ownerUserId); + /// Cancels and disposes every listener (called on shutdown). Task StopAllAsync(); } diff --git a/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs b/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs index 09202a7a..d13cab4d 100644 --- a/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs +++ b/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs @@ -106,6 +106,10 @@ await EnsureListenerAsync(registration.GuildId, registration.OwnerUserId, cancel } } + /// + public Task StopListenerAsync(ulong guildId, ulong ownerUserId) => + StopListenerAsync((guildId, ownerUserId)); + /// public async Task StopAllAsync() { diff --git a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs index db62b4c8..80fc3888 100644 --- a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs +++ b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs @@ -21,6 +21,7 @@ internal sealed class WorkspaceHostedService( { private readonly CancellationTokenSource _cts = new(); private Task? _connectionStatusLoop; + private Task? _serverCredentialsLoop; private Task? _serverRegisteredLoop; private bool _startupDone; @@ -34,6 +35,7 @@ public Task StartAsync(CancellationToken cancellationToken) client.ChannelDestroyed += OnChannelDestroyedAsync; _serverRegisteredLoop = Task.Run(() => ConsumeServerRegisteredAsync(_cts.Token), CancellationToken.None); _connectionStatusLoop = Task.Run(() => ConsumeConnectionStatusAsync(_cts.Token), CancellationToken.None); + _serverCredentialsLoop = Task.Run(() => ConsumeServerCredentialsAsync(_cts.Token), CancellationToken.None); return Task.CompletedTask; } @@ -45,7 +47,7 @@ public async Task StopAsync(CancellationToken cancellationToken) await _cts.CancelAsync().ConfigureAwait(false); foreach (var loop in new[] { - _serverRegisteredLoop, _connectionStatusLoop + _serverRegisteredLoop, _connectionStatusLoop, _serverCredentialsLoop }) { if (loop is null) @@ -136,6 +138,32 @@ await reconciler.ReconcileServerAsync(changed.GuildId, changed.ServerId, cancell } } + private async Task ConsumeServerCredentialsAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var changed in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var reconciler = scope.ServiceProvider.GetRequiredService(); + await reconciler.ReconcileServerAsync(changed.GuildId, changed.ServerId, cancellationToken) + .ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + // Shutting down. + } + catch (Exception ex) // Broad catch is intentional: a faulting consumer must not crash the host. + { + logger.LogError(ex, "ServerCredentialsChanged consumer faulted."); + } + } + private async Task ConsumeServerRegisteredAsync(CancellationToken cancellationToken) { // Subscription is registered when this loop first calls SubscribeAsync; the in-process bus does diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs index 408c7630..65789731 100644 --- a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs @@ -25,6 +25,8 @@ internal sealed class LocalizationCatalog ["setup.body"] = "Click **Connect account** below and paste your Rust+ FCM credentials. Then pair a server in-game and its channels appear automatically.", ["setup.connect.button"] = "Connect account", + ["setup.disconnect.button"] = "Disconnect account", + ["server.info.remove.button"] = "Remove server", ["settings.title"] = "Settings", ["settings.body"] = "Configure the bot for this server.", ["settings.language.label"] = "Language", @@ -54,6 +56,8 @@ internal sealed class LocalizationCatalog ["setup.body"] = "Cliquez sur **Connecter le compte** ci-dessous et collez vos identifiants FCM Rust+. Appairez ensuite un serveur en jeu et ses salons apparaitront automatiquement.", ["setup.connect.button"] = "Connecter le compte", + ["setup.disconnect.button"] = "Déconnecter le compte", + ["server.info.remove.button"] = "Supprimer le serveur", ["settings.title"] = "Parametres", ["settings.body"] = "Configurez le bot pour ce serveur.", ["settings.language.label"] = "Langue", diff --git a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs index bde68d28..c49185ad 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs @@ -65,7 +65,7 @@ public async ValueTask RenderAsync(MessageRenderContext context, .Where(c => c.Status != CredentialStatus.Invalid) .Take(SelectMenuBuilder.MaxOptionCount) // Discord hard-limits a select menu to 25 options. .ToList(); - MessageComponent? components = null; + var builder = new ComponentBuilder(); if (eligible.Count > 0) { var select = new SelectMenuBuilder() @@ -77,10 +77,17 @@ public async ValueTask RenderAsync(MessageRenderContext context, select.AddOption(label, credential.Id.ToString(), isDefault: credential.Id == active?.Id); } - components = new ComponentBuilder().WithSelectMenu(select).Build(); + builder.WithSelectMenu(select, row: 0); } - return new MessagePayload(null, embed.Build(), components); + // Keep the remove button on its own row: Discord rejects an action row that mixes a select with buttons. + builder.WithButton( + localizer.Get("server.info.remove.button", context.Culture), + $"{WorkspaceComponentIds.ServerInfoRemovePrefix}{serverId}", + ButtonStyle.Danger, + row: eligible.Count > 0 ? 1 : 0); + + return new MessagePayload(null, embed.Build(), builder.Build()); } private static string Glyph(ConnectionStatus status) => status switch diff --git a/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs index 8b0c2843..24bdf22b 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs @@ -26,6 +26,10 @@ public ValueTask RenderAsync(MessageRenderContext context, Cance localizer.Get("setup.connect.button", context.Culture), WorkspaceComponentIds.ConnectAccount, ButtonStyle.Primary) + .WithButton( + localizer.Get("setup.disconnect.button", context.Culture), + WorkspaceComponentIds.DisconnectAccount, + ButtonStyle.Danger) .Build(); return ValueTask.FromResult(new MessagePayload(null, embed, components)); } diff --git a/src/RustPlusBot.Features.Workspace/Teardown/IServerWorkspaceRemover.cs b/src/RustPlusBot.Features.Workspace/Teardown/IServerWorkspaceRemover.cs new file mode 100644 index 00000000..cff6a231 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Teardown/IServerWorkspaceRemover.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Features.Workspace.Teardown; + +/// Public seam to remove one server's provisioned Discord resources (used by the Connections removal flow). +public interface IServerWorkspaceRemover +{ + /// Deletes one server's category, channels, and provisioning records. + /// The guild. + /// The server to remove. + /// A cancellation token. + /// A task. + Task RemoveServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs b/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs index 0fe5d9fe..f2e358d5 100644 --- a/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs +++ b/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs @@ -11,7 +11,7 @@ namespace RustPlusBot.Features.Workspace.Teardown; internal sealed class WorkspaceTeardownService( IWorkspaceGateway gateway, IWorkspaceStore store, - IProvisioningLock provisioningLock) : IWorkspaceTeardownService + IProvisioningLock provisioningLock) : IWorkspaceTeardownService, IServerWorkspaceRemover { /// public async Task RemoveServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default) diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs index e86a8228..cfba54dd 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs @@ -11,4 +11,10 @@ public static class WorkspaceComponentIds /// Prefix for the #info "swap active player" select; the server id is appended. Handled by Connections. public const string ServerInfoSwapPrefix = "workspace:info:swap:"; + + /// The #setup "Disconnect account" button; handled by the Pairing feature. + public const string DisconnectAccount = "workspace:setup:disconnect"; + + /// Prefix for the #info "remove server" button; the server id is appended. Handled by Connections. + public const string ServerInfoRemovePrefix = "workspace:info:remove:"; } diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index ff060634..40034e6b 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -41,9 +41,12 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) services.AddScoped(); services.AddScoped(); - // Reconciler + teardown (scoped). + // Reconciler + teardown (scoped). Register the teardown service once and expose both interfaces + // off the same scoped instance, so resolving either does not create a second instance. services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(sp => sp.GetRequiredService()); // Options (Host binds the "Workspace" section; default = danger commands off). services.AddOptions(); diff --git a/src/RustPlusBot.Persistence/Configurations/ConnectionStateConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ConnectionStateConfiguration.cs index a413393f..08f33066 100644 --- a/src/RustPlusBot.Persistence/Configurations/ConnectionStateConfiguration.cs +++ b/src/RustPlusBot.Persistence/Configurations/ConnectionStateConfiguration.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using RustPlusBot.Domain.Connections; +using RustPlusBot.Domain.Servers; namespace RustPlusBot.Persistence.Configurations; @@ -10,5 +11,11 @@ public void Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.HasKey(s => s.RustServerId); + + // Removing a RustServer cascades to its single connection-state row so no orphaned status lingers. + builder.HasOne() + .WithOne() + .HasForeignKey(s => s.RustServerId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs index 6a02f12c..f1cb19ba 100644 --- a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs +++ b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs @@ -61,4 +61,41 @@ public Task CountForServerAsync( context.PlayerCredentials .Where(c => c.GuildId == guildId && c.RustServerId == rustServerId) .CountAsync(cancellationToken); + + /// + public async Task> RemoveForOwnerAsync( + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default) + { + // Project the affected server ids first (no token material loaded), then delete set-based. + var serverIds = await context.PlayerCredentials + .Where(c => c.GuildId == guildId && c.OwnerUserId == ownerUserId) + .Select(c => c.RustServerId) + .Distinct() + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + if (serverIds.Count == 0) + { + return []; + } + + await context.PlayerCredentials + .Where(c => c.GuildId == guildId && c.OwnerUserId == ownerUserId) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + return serverIds; + } + + /// + public async Task> ListServerIdsForOwnerAsync( + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default) => + await context.PlayerCredentials + .Where(c => c.GuildId == guildId && c.OwnerUserId == ownerUserId) + .Select(c => c.RustServerId) + .Distinct() + .ToListAsync(cancellationToken) + .ConfigureAwait(false); } diff --git a/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.Designer.cs new file mode 100644 index 00000000..28d745ea --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.Designer.cs @@ -0,0 +1,474 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RustPlusBot.Persistence; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + [DbContext(typeof(BotDbContext))] + [Migration("20260615183313_ServerRemovalCascade")] + partial class ServerRemovalCascade + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.HasIndex("ParentId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.GuildEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Guilds"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.MemberEntity", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Nickname") + .HasColumnType("TEXT"); + + b.HasKey("GuildId", "UserId"); + + b.ToTable("Members"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.RoleEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.UserEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("GlobalName") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => + { + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("ActiveCredentialId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("RustServerId"); + + b.ToTable("ConnectionStates"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.FcmRegistration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("ProtectedFcmCredentials") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "OwnerUserId") + .IsUnique(); + + b.ToTable("FcmRegistrations"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("ProtectedPlayerToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SteamId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "OwnerUserId") + .IsUnique(); + + b.ToTable("PlayerCredentials"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Entities.PairedEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "RustServerId"); + + b.ToTable("PairedEntities"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Events.EventSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EventKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "RustServerId"); + + b.ToTable("EventSubscriptions"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Guilds.GuildSettings", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("GuildId"); + + b.ToTable("GuildSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedByUserId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Ip") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.HasIndex("GuildId", "Ip", "Port") + .IsUnique(); + + b.ToTable("RustServers"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordCategoryId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId") + .IsUnique(); + + b.ToTable("ProvisionedCategories"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChannelKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "ChannelKey") + .IsUnique(); + + b.ToTable("ProvisionedChannels"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("DiscordMessageId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "MessageKey") + .IsUnique(); + + b.ToTable("ProvisionedMessages"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b => + { + b.HasOne("Persistord.Core.Entities.ChannelEntity", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Connections.ConnectionState", "RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.cs b/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.cs new file mode 100644 index 00000000..da08ee9f --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + /// + public partial class ServerRemovalCascade : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Defensive: remove any ConnectionStates orphaned before this FK existed so adding the + // constraint cannot fail. No server-removal path existed before 1b-iii, so this is normally a no-op. + migrationBuilder.Sql( + "DELETE FROM \"ConnectionStates\" WHERE \"RustServerId\" NOT IN (SELECT \"Id\" FROM \"RustServers\");"); + + migrationBuilder.AddForeignKey( + name: "FK_ConnectionStates_RustServers_RustServerId", + table: "ConnectionStates", + column: "RustServerId", + principalTable: "RustServers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ConnectionStates_RustServers_RustServerId", + table: "ConnectionStates"); + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index 0bfd8c27..dc340355 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -125,7 +125,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => { b.Property("RustServerId") - .ValueGeneratedOnAdd() .HasColumnType("TEXT"); b.Property("ActiveCredentialId") @@ -425,6 +424,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Connections.ConnectionState", "RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => { b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionHostedServiceTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionHostedServiceTests.cs index afe26086..df315f6b 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionHostedServiceTests.cs @@ -35,4 +35,28 @@ public async Task Startup_CallsStartAll_And_ServerRegistered_EnsuresAConnection( await service.StopAsync(default); await supervisor.Received(1).StopAllAsync(); } + + [Fact] + public async Task ServerCredentialsChanged_EnsuresAConnection() + { + var supervisor = Substitute.For(); + var bus = new InMemoryEventBus(); + var service = new ConnectionHostedService(supervisor, bus, NullLogger.Instance); + + await service.StartAsync(default); + + var serverId = Guid.NewGuid(); + var deadline = DateTimeOffset.UtcNow.AddSeconds(20); + while (DateTimeOffset.UtcNow < deadline + && !supervisor.ReceivedCalls().Any(c => + c.GetMethodInfo().Name == nameof(IConnectionSupervisor.EnsureConnectionAsync))) + { + await bus.PublishAsync(new ServerCredentialsChangedEvent(10UL, serverId)); + await Task.Delay(20); + } + + await supervisor.Received().EnsureConnectionAsync(10UL, serverId, Arg.Any()); + + await service.StopAsync(default); + } } diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs index 280ab2df..0a12d577 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs @@ -6,7 +6,9 @@ using RustPlusBot.Abstractions.Time; using RustPlusBot.Discord.Notifications; using RustPlusBot.Features.Connections; +using RustPlusBot.Features.Connections.Removal; using RustPlusBot.Features.Connections.Supervisor; +using RustPlusBot.Features.Workspace.Teardown; using RustPlusBot.Persistence; using RustPlusBot.Persistence.Connections; @@ -26,6 +28,7 @@ public async Task Services_Resolve() services.AddLogging(); services.AddBotPersistence("DataSource=:memory:"); services.AddOptions(); + services.AddSingleton(Substitute.For()); services.AddConnections(); await using var provider = services.BuildServiceProvider(new ServiceProviderOptions @@ -36,5 +39,6 @@ public async Task Services_Resolve() Assert.NotNull(provider.GetRequiredService()); await using var scope = provider.CreateAsyncScope(); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); } } diff --git a/tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj b/tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj index 7f286737..5586b02b 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj +++ b/tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/RustPlusBot.Features.Connections.Tests/ServerRemovalServiceTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ServerRemovalServiceTests.cs new file mode 100644 index 00000000..87864095 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/ServerRemovalServiceTests.cs @@ -0,0 +1,49 @@ +using NSubstitute; +using RustPlusBot.Features.Connections.Removal; +using RustPlusBot.Features.Connections.Supervisor; +using RustPlusBot.Features.Workspace.Teardown; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class ServerRemovalServiceTests +{ + [Fact] + public async Task RemoveServer_Stops_Deletes_TearsDown_InOrder() + { + var serverId = Guid.NewGuid(); + var supervisor = Substitute.For(); + var servers = Substitute.For(); + servers.RemoveAsync(10UL, serverId, Arg.Any()).Returns(true); + var workspace = Substitute.For(); + var sut = new ServerRemovalService(supervisor, servers, workspace); + + var removed = await sut.RemoveServerAsync(10UL, serverId); + + Assert.True(removed); +#pragma warning disable VSTHRD110 // Received.InOrder requires unawaited calls inside its synchronous ordering lambda — this is the NSubstitute-prescribed pattern. + Received.InOrder(() => + { + supervisor.StopAsync(10UL, serverId); + servers.RemoveAsync(10UL, serverId, Arg.Any()); + workspace.RemoveServerAsync(10UL, serverId, Arg.Any()); + }); +#pragma warning restore VSTHRD110 + } + + [Fact] + public async Task RemoveServer_WhenServerAbsent_ReturnsFalse_StillTearsDown() + { + var serverId = Guid.NewGuid(); + var supervisor = Substitute.For(); + var servers = Substitute.For(); + servers.RemoveAsync(10UL, serverId, Arg.Any()).Returns(false); + var workspace = Substitute.For(); + var sut = new ServerRemovalService(supervisor, servers, workspace); + + var removed = await sut.RemoveServerAsync(10UL, serverId); + + Assert.False(removed); + await workspace.Received(1).RemoveServerAsync(10UL, serverId, Arg.Any()); + } +} diff --git a/tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs new file mode 100644 index 00000000..5e9de86b --- /dev/null +++ b/tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs @@ -0,0 +1,117 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Pairing.Accounts; +using RustPlusBot.Features.Pairing.Supervisor; +using RustPlusBot.Persistence.Credentials; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Pairing.Tests; + +public sealed class AccountDisconnectServiceTests +{ + private static readonly string[] RustopiaNames = ["Rustopia"]; + + private static (AccountDisconnectService Sut, IPairingSupervisor Sup, IFcmRegistrationStore Regs, + ICredentialStore Creds, IServerService Servers, IEventBus Bus) Create() + { + var sup = Substitute.For(); + var regs = Substitute.For(); + var creds = Substitute.For(); + var servers = Substitute.For(); + var bus = Substitute.For(); + return (new AccountDisconnectService(sup, regs, creds, servers, bus), sup, regs, creds, servers, bus); + } + + [Fact] + public async Task Disconnect_StopsListener_DisablesRegistration_RemovesCreds_PublishesPerServer() + { + var (sut, sup, regs, creds, _, bus) = Create(); + var regId = Guid.NewGuid(); + var s1 = Guid.NewGuid(); + var s2 = Guid.NewGuid(); + regs.GetAsync(10UL, 99UL, Arg.Any()) + .Returns(new FcmRegistration + { + Id = regId, GuildId = 10UL, OwnerUserId = 99UL + }); + creds.RemoveForOwnerAsync(10UL, 99UL, Arg.Any()) + .Returns(new List + { + s1, s2 + }); + + var count = await sut.DisconnectAsync(10UL, 99UL); + + Assert.Equal(2, count); + await sup.Received(1).StopListenerAsync(10UL, 99UL); + await regs.Received(1).SetStatusAsync(regId, FcmRegistrationStatus.Disabled, Arg.Any()); + await bus.Received(1).PublishAsync( + Arg.Is(e => e.GuildId == 10UL && e.ServerId == s1), + Arg.Any()); + await bus.Received(1).PublishAsync( + Arg.Is(e => e.ServerId == s2), Arg.Any()); + } + + [Fact] + public async Task Disconnect_WhenNothingStored_StopsListener_NoEvents_ReturnsZero() + { + var (sut, sup, regs, creds, _, bus) = Create(); + regs.GetAsync(10UL, 99UL, Arg.Any()).Returns((FcmRegistration?)null); + creds.RemoveForOwnerAsync(10UL, 99UL, Arg.Any()).Returns(new List()); + + var count = await sut.DisconnectAsync(10UL, 99UL); + + Assert.Equal(0, count); + await sup.Received(1).StopListenerAsync(10UL, 99UL); + await regs.DidNotReceive().SetStatusAsync(Arg.Any(), Arg.Any(), + Arg.Any()); + await bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Preview_ReportsConnectedAndServerNames() + { + var (sut, _, regs, creds, servers, _) = Create(); + var s1 = Guid.NewGuid(); + regs.GetAsync(10UL, 99UL, Arg.Any()) + .Returns(new FcmRegistration + { + Id = Guid.NewGuid(), GuildId = 10UL, OwnerUserId = 99UL + }); + creds.ListServerIdsForOwnerAsync(10UL, 99UL, Arg.Any()) + .Returns(new List + { + s1 + }); + servers.GetAsync(10UL, s1, Arg.Any()) + .Returns(new RustServer + { + Id = s1, + GuildId = 10UL, + Name = "Rustopia", + Ip = "1.1.1.1", + Port = 28015 + }); + + var preview = await sut.PreviewAsync(10UL, 99UL); + + Assert.True(preview.IsConnected); + Assert.Equal(RustopiaNames, preview.AffectedServerNames); + } + + [Fact] + public async Task Preview_WhenNothing_ReportsNotConnected() + { + var (sut, _, regs, creds, _, _) = Create(); + regs.GetAsync(10UL, 99UL, Arg.Any()).Returns((FcmRegistration?)null); + creds.ListServerIdsForOwnerAsync(10UL, 99UL, Arg.Any()).Returns(new List()); + + var preview = await sut.PreviewAsync(10UL, 99UL); + + Assert.False(preview.IsConnected); + Assert.Empty(preview.AffectedServerNames); + } +} diff --git a/tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs b/tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs index 19aa4b7b..0b86b3fa 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs @@ -10,9 +10,14 @@ internal sealed class FakePairingSource : IPairingSource private int _createCount; + private int _disposeCount; + /// How many listeners have been created. public int CreateCount => Volatile.Read(ref _createCount); + /// How many created listeners have been disposed. + public int DisposeCount => Volatile.Read(ref _disposeCount); + /// The last notification callback registered by the supervisor. public Func? LastCallback { get; private set; } @@ -27,14 +32,16 @@ public IPairingListener Create( Interlocked.Increment(ref _createCount); LastCallback = onNotification; var outcome = _outcomes.TryDequeue(out var next) ? next : PairingConnectOutcome.Connected; - return new FakeListener(outcome, () => ConnectedSignal.TrySetResult()); + return new FakeListener(outcome, () => ConnectedSignal.TrySetResult(), + () => Interlocked.Increment(ref _disposeCount)); } /// Enqueues an outcome to be returned by the next listener created. /// The outcome the next listener will return from ConnectAsync. public void EnqueueOutcome(PairingConnectOutcome outcome) => _outcomes.Enqueue(outcome); - private sealed class FakeListener(PairingConnectOutcome outcome, Action onConnected) : IPairingListener + private sealed class FakeListener(PairingConnectOutcome outcome, Action onConnected, Action onDisposed) + : IPairingListener { public Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) { @@ -46,6 +53,10 @@ public Task ConnectAsync(TimeSpan timeout, CancellationTo return Task.FromResult(outcome); } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public ValueTask DisposeAsync() + { + onDisposed(); + return ValueTask.CompletedTask; + } } } diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs index 5da9d524..730cca0e 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs @@ -6,6 +6,7 @@ using RustPlusBot.Abstractions.Time; using RustPlusBot.Discord.Notifications; using RustPlusBot.Features.Pairing; +using RustPlusBot.Features.Pairing.Accounts; using RustPlusBot.Features.Pairing.Pairing; using RustPlusBot.Features.Pairing.Supervisor; using RustPlusBot.Persistence; @@ -36,5 +37,6 @@ public async Task Services_Resolve() Assert.NotNull(provider.GetRequiredService()); await using var scope = provider.CreateAsyncScope(); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); } } diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs index 334aa34a..50d61ef2 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs @@ -171,6 +171,31 @@ public async Task EnsureListener_CalledTwiceForSameOwner_RestartsListener() Assert.Equal(2, h.Source.CreateCount); } + [Fact] + public async Task StopListener_DisposesTheRunningListener() + { + var source = new FakePairingSource(); + source.EnqueueOutcome(PairingConnectOutcome.Connected); + await using var h = CreateHarness(source); + await SeedRegistrationAsync(h.Provider, 10UL, 99UL); + await h.Supervisor.EnsureListenerAsync(10UL, 99UL); + + await h.Supervisor.StopListenerAsync(10UL, 99UL); + + Assert.Equal(1, h.Source.DisposeCount); + } + +#pragma warning disable S2699 // The implicit assertion is "no exception is thrown". + [Fact] + public async Task StopListener_WhenNotRunning_DoesNotThrow() + { + var source = new FakePairingSource(); + await using var h = CreateHarness(source); + + await h.Supervisor.StopListenerAsync(10UL, 12345UL); + } +#pragma warning restore S2699 + private sealed class Harness : IAsyncDisposable { public required ServiceProvider Provider { get; init; } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs index 5ae3d204..b7570504 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs @@ -40,4 +40,34 @@ public async Task ConnectionStatusChanged_ReconcilesThatServer() await service.StopAsync(default); } + + [Fact] + public async Task ServerCredentialsChanged_ReconcilesThatServer() + { + var reconciler = Substitute.For(); + var services = new ServiceCollection(); + services.AddScoped(_ => reconciler); + await using var provider = services.BuildServiceProvider(); + + var bus = new InMemoryEventBus(); + var client = new DiscordSocketClient(); + var service = new WorkspaceHostedService(client, bus, + provider.GetRequiredService(), + NullLogger.Instance); + + await service.StartAsync(default); + var serverId = Guid.NewGuid(); + var deadline = DateTimeOffset.UtcNow.AddSeconds(20); + while (DateTimeOffset.UtcNow < deadline + && !reconciler.ReceivedCalls().Any(c => + c.GetMethodInfo().Name == nameof(IWorkspaceReconciler.ReconcileServerAsync))) + { + await bus.PublishAsync(new ServerCredentialsChangedEvent(10UL, serverId)); + await Task.Delay(20); + } + + await reconciler.Received().ReconcileServerAsync(10UL, serverId, Arg.Any()); + + await service.StopAsync(default); + } } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs index d7d341a2..dae247cb 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs @@ -182,4 +182,97 @@ public async Task Setup_HasConnectAccountButton() .SelectMany(r => r.Components).OfType(); Assert.Contains(buttons, b => b.CustomId == "workspace:setup:connect"); } + + [Fact] + public async Task Setup_HasDisconnectAccountButton() + { + var renderer = new SetupMessageRenderer(Loc); + + var payload = await renderer.RenderAsync(Global, default); + + var buttons = payload.Components!.Components.OfType() + .SelectMany(r => r.Components).OfType(); + Assert.Contains(buttons, b => b.CustomId == "workspace:setup:disconnect"); + } + + [Fact] + public async Task ServerInfo_HasRemoveServerButton() + { + var serverId = Guid.NewGuid(); + var servers = Substitute.For(); + servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "S", + Ip = "1.2.3.4", + Port = 28015 + }); + var connections = Substitute.For(); + connections.GetStateAsync(1, serverId, Arg.Any()) + .Returns(new DomainConnectionState + { + RustServerId = serverId, GuildId = 1, Status = ConnectionStatus.NoCredentials + }); + connections.ListPoolAsync(1, serverId, Arg.Any()) + .Returns(new List()); + var renderer = new ServerInfoMessageRenderer(servers, connections, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + var buttons = payload.Components!.Components.OfType() + .SelectMany(r => r.Components).OfType(); + Assert.Contains(buttons, b => b.CustomId == $"workspace:info:remove:{serverId}"); + } + + [Fact] + public async Task ServerInfo_SwapSelectAndRemoveButton_AreInSeparateActionRows() + { + var serverId = Guid.NewGuid(); + var credId = Guid.NewGuid(); + var servers = Substitute.For(); + servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "S", + Ip = "1.2.3.4", + Port = 28015 + }); + var connections = Substitute.For(); + connections.GetStateAsync(1, serverId, Arg.Any()) + .Returns(new DomainConnectionState + { + RustServerId = serverId, + GuildId = 1, + ActiveCredentialId = credId, + Status = ConnectionStatus.Connected, + PlayerCount = 1, + }); + connections.ListPoolAsync(1, serverId, Arg.Any()) + .Returns(new List + { + new() + { + Id = credId, + GuildId = 1, + RustServerId = serverId, + OwnerUserId = 7, + SteamId = 5UL, + Status = CredentialStatus.Active + }, + }); + var renderer = new ServerInfoMessageRenderer(servers, connections, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + var rows = payload.Components!.Components.OfType().ToList(); + // Discord rejects an action row that mixes a select menu with buttons. + Assert.DoesNotContain(rows, r => + r.Components.OfType().Any() && r.Components.OfType().Any()); + Assert.Contains(rows, r => r.Components.OfType().Any()); + Assert.Contains(rows, r => r.Components.OfType().Any()); + } } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs index de321767..159c113c 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs @@ -30,5 +30,6 @@ public void ReconcilerAndTeardown_ResolveFromScope() Assert.NotNull(scope.ServiceProvider.GetRequiredService()); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); } } diff --git a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs index fba322d3..801343a6 100644 --- a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using RustPlusBot.Domain.Connections; +using RustPlusBot.Domain.Servers; namespace RustPlusBot.Persistence.Tests.Connections; @@ -12,7 +13,13 @@ public async Task ConnectionState_RoundTrips_StatusAndPlayerCount() await using var _ = context; await using var __ = connection; - var serverId = Guid.NewGuid(); + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + var serverId = server.Id; context.ConnectionStates.Add(new ConnectionState { RustServerId = serverId, @@ -36,7 +43,13 @@ public async Task ConnectionState_RoundTrips_NullPlayerCount() await using var _ = context; await using var __ = connection; - var serverId = Guid.NewGuid(); + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + var serverId = server.Id; context.ConnectionStates.Add(new ConnectionState { RustServerId = serverId, @@ -49,4 +62,31 @@ public async Task ConnectionState_RoundTrips_NullPlayerCount() var read = await context.ConnectionStates.SingleAsync(s => s.RustServerId == serverId); Assert.Null(read.PlayerCount); } + + [Fact] + public async Task RemovingServer_CascadeDeletesItsConnectionState() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + context.ConnectionStates.Add(new ConnectionState + { + RustServerId = server.Id, + GuildId = 10UL, + Status = ConnectionStatus.Connected, + UpdatedAt = DateTimeOffset.UnixEpoch, + }); + await context.SaveChangesAsync(); + + context.RustServers.Remove(server); + await context.SaveChangesAsync(); + + Assert.Empty(await context.ConnectionStates.ToListAsync()); + } } diff --git a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs index 4a630797..e1a41316 100644 --- a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs @@ -55,7 +55,13 @@ public async Task UpsertStatus_InsertsThenReportsChangeOnlyWhenDifferent() var (store, context, conn) = Create(); await using var _ = conn; await using var __ = context; - var serverId = Guid.NewGuid(); + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + var serverId = server.Id; Assert.True(await store.UpsertStatusAsync(10UL, serverId, ConnectionStatus.Connecting, null, null)); Assert.True(await store.UpsertStatusAsync(10UL, serverId, ConnectionStatus.Connected, 5, null)); diff --git a/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs index a5f4e78e..0726abe2 100644 --- a/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs @@ -113,4 +113,67 @@ await store.UpsertFromPairingAsync(new StoreCredentialRequest(20UL, serverAGuild Assert.Equal(2, await store.CountForServerAsync(10UL, serverA)); } + + [Fact] + public async Task RemoveForOwner_RemovesOwnersCredsAcrossServers_ReturnsDistinctServerIds_LeavesOthers() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var serverA = await SeedServerAsync(context, 10UL); + var serverB = await SeedServerAsync(context, 10UL, port: 28016); + var store = new CredentialStore(context, PassThroughProtector()); + + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverA, 1UL, 1UL, "t1"), markActive: true); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverB, 1UL, 1UL, "t2"), markActive: true); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverA, 2UL, 2UL, "t3"), + markActive: false); + var serverOtherGuild = await SeedServerAsync(context, 20UL); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(20UL, serverOtherGuild, 1UL, 1UL, "t4"), + markActive: true); + + var affected = await store.RemoveForOwnerAsync(10UL, 1UL); + + Assert.Equal(2, affected.Count); + Assert.Contains(serverA, affected); + Assert.Contains(serverB, affected); + Assert.DoesNotContain(serverOtherGuild, affected); + var remaining = await context.PlayerCredentials.ToListAsync(); + Assert.Equal(2, remaining.Count); + Assert.Contains(remaining, c => c.GuildId == 10UL && c.OwnerUserId == 2UL); + Assert.Contains(remaining, c => c.GuildId == 20UL && c.OwnerUserId == 1UL); + } + + [Fact] + public async Task RemoveForOwner_WhenNothingOwned_ReturnsEmpty() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + await SeedServerAsync(context, 10UL); + var store = new CredentialStore(context, PassThroughProtector()); + + var affected = await store.RemoveForOwnerAsync(10UL, 999UL); + + Assert.Empty(affected); + } + + [Fact] + public async Task ListServerIdsForOwner_ReturnsDistinctServers() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var serverA = await SeedServerAsync(context, 10UL); + var serverB = await SeedServerAsync(context, 10UL, port: 28016); + var store = new CredentialStore(context, PassThroughProtector()); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverA, 1UL, 1UL, "t1"), markActive: true); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverB, 1UL, 1UL, "t2"), markActive: true); + + var ids = await store.ListServerIdsForOwnerAsync(10UL, 1UL); + + Assert.Equal(2, ids.Count); + Assert.Contains(serverA, ids); + Assert.Contains(serverB, ids); + } }