diff --git a/Directory.Packages.props b/Directory.Packages.props index a93116e0..25bf9341 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,7 +12,8 @@ - + + @@ -38,6 +39,7 @@ + diff --git a/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs index 47290f85..112e57b6 100644 --- a/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs +++ b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs @@ -1,16 +1,14 @@ +using Persistord.Core.Abstractions; using RustPlusBot.Abstractions.Connections; namespace RustPlusBot.Domain.Alarms; /// A paired Smart Alarm the bot manages, surviving restarts. Guild- and server-scoped. Driven by the live socket (primed on connect, reacts to SmartDeviceTriggered) — the entity id is the switch-vs-alarm discriminant. -public sealed class SmartAlarm +public sealed class SmartAlarm : IGuildScoped, ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - /// The server this alarm belongs to (FK to RustServer, cascade delete). public Guid ServerId { get; set; } @@ -26,9 +24,6 @@ public sealed class SmartAlarm /// The Discord user who accepted (validated) the pairing. public ulong PairedByUserId { get; set; } - /// When the alarm was accepted (UTC). - public DateTimeOffset CreatedUtc { get; set; } - /// When true, a trigger going active pings @everyone in #alarms. public bool PingEveryone { get; set; } @@ -43,4 +38,10 @@ public sealed class SmartAlarm /// Per-device reachability; defaults to Reachable. Orthogonal to whole-server connection status. public DeviceReachability Reachability { get; set; } = DeviceReachability.Reachable; + + /// When the alarm was accepted (UTC). Stamped by Persistord's TimestampInterceptor. + public DateTimeOffset CreatedAt { get; set; } + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs b/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs index 40b9db9c..c5fed779 100644 --- a/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs +++ b/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs @@ -1,14 +1,13 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Clans; /// /// A cached Steam64 id to display-name mapping. The clan API reports members by id only, so names /// are harvested from clan chat and team snapshots, which do carry them. /// -public sealed class ClanPlayerName +public sealed class ClanPlayerName : IGuildScoped, IUpdatedAt { - /// The owning guild snowflake. - public ulong GuildId { get; set; } - /// The server id (FK to RustServer). public Guid ServerId { get; set; } @@ -18,6 +17,9 @@ public sealed class ClanPlayerName /// The most recently observed display name. public string Name { get; set; } = string.Empty; - /// When the name was last observed (UTC). - public DateTimeOffset UpdatedUtc { get; set; } + /// The owning guild snowflake. + public ulong GuildId { get; set; } + + /// When the name was last observed (UTC). Stamped by Persistord's TimestampInterceptor. + public DateTimeOffset UpdatedAt { get; set; } } diff --git a/src/RustPlusBot.Domain/Clans/ClanState.cs b/src/RustPlusBot.Domain/Clans/ClanState.cs index e121b5c3..2982df17 100644 --- a/src/RustPlusBot.Domain/Clans/ClanState.cs +++ b/src/RustPlusBot.Domain/Clans/ClanState.cs @@ -1,14 +1,13 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Clans; /// /// The latest known clan snapshot for one (guild, server). Row presence is the single source of /// truth for whether the paired player is in a clan. /// -public sealed class ClanState +public sealed class ClanState : IGuildScoped { - /// The owning guild snowflake. - public ulong GuildId { get; set; } - /// The server id (FK to RustServer; primary key, one row per server). public Guid ServerId { get; set; } @@ -56,4 +55,7 @@ public sealed class ClanState /// When this snapshot was last confirmed (UTC). public DateTimeOffset LastSeenUtc { get; set; } + + /// The owning guild snowflake. + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs b/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs index f58560af..c73027f8 100644 --- a/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs +++ b/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs @@ -1,11 +1,10 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Commands; /// Per-(guild, server) command configuration: trigger prefix and mute state. -public sealed class ServerCommandSettings +public sealed class ServerCommandSettings : IGuildScoped { - /// The owning guild snowflake. - public ulong GuildId { get; set; } - /// The server id (FK to RustServer; primary key, one row per server). public Guid ServerId { get; set; } @@ -14,4 +13,7 @@ public sealed class ServerCommandSettings /// Whether all bot-to-game output is currently muted. public bool Muted { get; set; } + + /// The owning guild snowflake. + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/Connections/ConnectionState.cs b/src/RustPlusBot.Domain/Connections/ConnectionState.cs index 80205042..f11c644c 100644 --- a/src/RustPlusBot.Domain/Connections/ConnectionState.cs +++ b/src/RustPlusBot.Domain/Connections/ConnectionState.cs @@ -1,14 +1,13 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Connections; /// Persisted last-known connection state per server, so the active identity and status survive restarts. -public sealed class ConnectionState +public sealed class ConnectionState : IGuildScoped, IUpdatedAt { /// The server this state belongs to (primary key, one row per server). public Guid RustServerId { get; set; } - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - /// The credential currently selected as active, if any. public Guid? ActiveCredentialId { get; set; } @@ -18,6 +17,9 @@ public sealed class ConnectionState /// Last heartbeat player count, or null if unknown. public int? PlayerCount { get; set; } - /// When the state was last updated (UTC). + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } + + /// When the state was last updated (UTC). Stamped by Persistord's TimestampInterceptor. public DateTimeOffset UpdatedAt { get; set; } } diff --git a/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs b/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs index 7e94458c..9460a089 100644 --- a/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs +++ b/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs @@ -1,17 +1,16 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Credentials; /// /// One Discord user's Rust+ FCM listener registration within a guild. One per (GuildId, OwnerUserId). /// The credentials blob is stored protected at rest (see ICredentialProtector) and feeds the pairing listener. /// -public sealed class FcmRegistration +public sealed class FcmRegistration : IGuildScoped, IUpdatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - /// The Discord user who connected these credentials. public ulong OwnerUserId { get; set; } @@ -21,6 +20,9 @@ public sealed class FcmRegistration /// Listener lifecycle state. public FcmRegistrationStatus Status { get; set; } = FcmRegistrationStatus.Active; - /// When the registration was last upserted or had its status changed (UTC). + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } + + /// When the registration was last written (UTC). Stamped by Persistord's TimestampInterceptor. public DateTimeOffset UpdatedAt { get; set; } } diff --git a/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs b/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs index f2b34a6b..b25da250 100644 --- a/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs +++ b/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs @@ -1,17 +1,16 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Credentials; /// /// One player's Rust+ credentials within a server's pool. Many per (GuildId, RustServerId). /// The token fields are stored protected at rest (see ICredentialProtector). /// -public sealed class PlayerCredential +public sealed class PlayerCredential : IGuildScoped { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - /// The server this credential can connect to. public Guid RustServerId { get; set; } @@ -26,4 +25,7 @@ public sealed class PlayerCredential /// Pool lifecycle state. public CredentialStatus Status { get; set; } = CredentialStatus.Standby; + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs b/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs index 3baa2b73..6826d9ca 100644 --- a/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs +++ b/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs @@ -1,3 +1,4 @@ +using Persistord.Core.Abstractions; using RustPlusBot.Abstractions.Connections; namespace RustPlusBot.Domain.Devices; @@ -14,14 +15,11 @@ namespace RustPlusBot.Domain.Devices; /// type would pull the base into the model and silently collapse both device tables into one /// table-per-hierarchy table. Each derived device keeps its own table; this base only shares the columns. /// -public abstract class PairedDeviceEntity +public abstract class PairedDeviceEntity : IGuildScoped, ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - /// The server this device belongs to (FK to RustServer, cascade delete). public Guid ServerId { get; set; } @@ -37,9 +35,12 @@ public abstract class PairedDeviceEntity /// The Discord user who accepted (validated) the pairing. public ulong PairedByUserId { get; set; } - /// When the pairing was accepted (UTC). - public DateTimeOffset CreatedUtc { get; set; } - /// Per-device reachability; defaults to Reachable. Orthogonal to whole-server connection status. public DeviceReachability Reachability { get; set; } = DeviceReachability.Reachable; + + /// When the pairing was accepted (UTC). Stamped by Persistord's TimestampInterceptor. + public DateTimeOffset CreatedAt { get; set; } + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/Entities/PairedEntity.cs b/src/RustPlusBot.Domain/Entities/PairedEntity.cs deleted file mode 100644 index 184b8e4b..00000000 --- a/src/RustPlusBot.Domain/Entities/PairedEntity.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace RustPlusBot.Domain.Entities; - -/// A paired in-game smart device, discovered via FCM pairing (populated in subsystem 1). -public sealed class PairedEntity -{ - /// Surrogate primary key. - public Guid Id { get; set; } = Guid.NewGuid(); - - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - - /// The server this entity belongs to. - public Guid RustServerId { get; set; } - - /// The in-game entity id. - public ulong EntityId { get; set; } - - /// The device kind. - public PairedEntityKind Kind { get; set; } - - /// User-facing label. - public string Name { get; set; } = string.Empty; -} diff --git a/src/RustPlusBot.Domain/Events/EventSubscription.cs b/src/RustPlusBot.Domain/Events/EventSubscription.cs deleted file mode 100644 index 75d1d41e..00000000 --- a/src/RustPlusBot.Domain/Events/EventSubscription.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace RustPlusBot.Domain.Events; - -/// A guild's opt-in to a named map/live event (e.g. "CargoShip"). Consumed in subsystem 2. -public sealed class EventSubscription -{ - /// Surrogate primary key. - public Guid Id { get; set; } = Guid.NewGuid(); - - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - - /// The server this subscription applies to. - public Guid RustServerId { get; set; } - - /// The event key the guild subscribed to. - public string EventKey { get; set; } = string.Empty; -} diff --git a/src/RustPlusBot.Domain/Guilds/GuildSettings.cs b/src/RustPlusBot.Domain/Guilds/GuildSettings.cs index d69200cb..d8ef44a3 100644 --- a/src/RustPlusBot.Domain/Guilds/GuildSettings.cs +++ b/src/RustPlusBot.Domain/Guilds/GuildSettings.cs @@ -1,14 +1,16 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Guilds; /// Per-guild configuration. Primary key is the guild snowflake. -public sealed class GuildSettings +public sealed class GuildSettings : IGuildScoped { - /// The Discord guild snowflake (primary key). - public ulong GuildId { get; set; } - /// BCP-47 culture for localized output (e.g. "en", "fr"). public string Culture { get; set; } = "en"; /// When true, the server-wiped announcement pings @everyone in #events. Off by default. public bool PingEveryoneOnWipe { get; set; } + + /// The Discord guild snowflake (primary key). + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/Map/ServerMapSettings.cs b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs index ce16aa7d..0804fee2 100644 --- a/src/RustPlusBot.Domain/Map/ServerMapSettings.cs +++ b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs @@ -1,13 +1,11 @@ +using Persistord.Core.Abstractions; using RustPlusBot.Abstractions.Connections; namespace RustPlusBot.Domain.Map; /// Per-(guild, server) rendered-map layer settings; one row per server. Layers default on. -public sealed class ServerMapSettings +public sealed class ServerMapSettings : IGuildScoped { - /// The owning guild snowflake. - public ulong GuildId { get; set; } - /// The server id (FK to RustServer; primary key, one row per server). public Guid ServerId { get; set; } @@ -34,4 +32,7 @@ public sealed class ServerMapSettings /// Which grid convention the rendered map and event grid references use. public MapGridStyle GridStyle { get; set; } = MapGridStyle.InGame; + + /// The owning guild snowflake. + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/RustPlusBot.Domain.csproj b/src/RustPlusBot.Domain/RustPlusBot.Domain.csproj index b160cd12..e2e9a392 100644 --- a/src/RustPlusBot.Domain/RustPlusBot.Domain.csproj +++ b/src/RustPlusBot.Domain/RustPlusBot.Domain.csproj @@ -4,4 +4,11 @@ + + + + + diff --git a/src/RustPlusBot.Domain/Servers/RustServer.cs b/src/RustPlusBot.Domain/Servers/RustServer.cs index 197dcc8c..68a5e84a 100644 --- a/src/RustPlusBot.Domain/Servers/RustServer.cs +++ b/src/RustPlusBot.Domain/Servers/RustServer.cs @@ -1,14 +1,13 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Servers; /// A Rust+ server target bound to a Discord guild. Guild-scoped. -public sealed class RustServer +public sealed class RustServer : IGuildScoped { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - /// Display name shown in Discord. public string Name { get; set; } = string.Empty; @@ -32,4 +31,7 @@ public sealed class RustServer /// Baseline: the last observed world size (game units), or null before first observation. public uint? LastMapSize { get; set; } + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs b/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs index 503a3768..5ff11f1e 100644 --- a/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs +++ b/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs @@ -1,14 +1,13 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Vending; /// A registered grid cell; every vending machine inside it counts as the team's own. -public sealed class VendingGridTrack +public sealed class VendingGridTrack : IGuildScoped, ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - /// The server this registration belongs to (FK to RustServer, cascade delete). public Guid ServerId { get; set; } @@ -18,6 +17,9 @@ public sealed class VendingGridTrack /// The Steam id of the player who registered the cell, for display. public ulong RegisteredBySteamId { get; set; } - /// When the cell was registered (UTC). - public DateTimeOffset CreatedUtc { get; set; } + /// When the cell was registered (UTC). Stamped by Persistord's TimestampInterceptor. + public DateTimeOffset CreatedAt { get; set; } + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs b/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs index 8f9c7a4a..a57de502 100644 --- a/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs +++ b/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs @@ -1,14 +1,13 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Vending; /// A listing the team sells, registered by hand rather than read off a machine. -public sealed class VendingListingTrack +public sealed class VendingListingTrack : IGuildScoped, ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - /// The server this listing belongs to (FK to RustServer, cascade delete). public Guid ServerId { get; set; } @@ -33,6 +32,9 @@ public sealed class VendingListingTrack /// The Discord user who registered the listing, for display. public ulong RegisteredByUserId { get; set; } - /// When the listing was registered (UTC). - public DateTimeOffset CreatedUtc { get; set; } + /// When the listing was registered (UTC). Stamped by Persistord's TimestampInterceptor. + public DateTimeOffset CreatedAt { get; set; } + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/Vending/VendingNotification.cs b/src/RustPlusBot.Domain/Vending/VendingNotification.cs index 65dd8c57..31eb22e3 100644 --- a/src/RustPlusBot.Domain/Vending/VendingNotification.cs +++ b/src/RustPlusBot.Domain/Vending/VendingNotification.cs @@ -1,3 +1,5 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Vending; /// @@ -5,14 +7,11 @@ namespace RustPlusBot.Domain.Vending; /// "did the owner reprice" is answerable without a second poll — an owner reprice deletes the message, /// whereas a rival's move only edits it. /// -public sealed class VendingNotification +public sealed class VendingNotification : IGuildScoped { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - /// The server this notification belongs to (FK to RustServer, cascade delete). public Guid ServerId { get; set; } @@ -39,4 +38,7 @@ public sealed class VendingNotification /// When the message was posted (UTC). public DateTimeOffset PostedUtc { get; set; } + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/Vending/VendingStockNotification.cs b/src/RustPlusBot.Domain/Vending/VendingStockNotification.cs index 14dc3252..1522e069 100644 --- a/src/RustPlusBot.Domain/Vending/VendingStockNotification.cs +++ b/src/RustPlusBot.Domain/Vending/VendingStockNotification.cs @@ -1,17 +1,16 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Vending; /// /// A live sell-out message in #vending, one per registered machine. Stores the sold-out set it was /// rendered against so an owner restock deletes the message rather than silently editing it. /// -public sealed class VendingStockNotification +public sealed class VendingStockNotification : IGuildScoped { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - /// The server this notification belongs to (FK to RustServer, cascade delete). public Guid ServerId { get; set; } @@ -29,4 +28,7 @@ public sealed class VendingStockNotification /// When the message was posted (UTC). public DateTimeOffset PostedUtc { get; set; } + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs index 48937caf..0eb28aea 100644 --- a/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs @@ -1,20 +1,22 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Workspace; /// A Discord category the bot has provisioned. One per scope (global or per-server). -public sealed class ProvisionedCategory +public sealed class ProvisionedCategory : IGuildScoped, ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - /// The server this category belongs to, or null for the global category. public Guid? RustServerId { get; set; } /// The provisioned Discord category snowflake. public ulong DiscordCategoryId { get; set; } - /// When the record was first created (UTC). + /// When the record was first created (UTC). Stamped by Persistord's TimestampInterceptor. public DateTimeOffset CreatedAt { get; set; } + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs index 853a1890..65f318b5 100644 --- a/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs @@ -1,14 +1,13 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Workspace; /// A Discord text channel the bot has provisioned, identified by its stable spec key. -public sealed class ProvisionedChannel +public sealed class ProvisionedChannel : IGuildScoped, ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - /// The server this channel belongs to, or null for a global channel. public Guid? RustServerId { get; set; } @@ -18,6 +17,9 @@ public sealed class ProvisionedChannel /// The provisioned Discord channel snowflake. public ulong DiscordChannelId { get; set; } - /// When the record was first created (UTC). + /// When the record was first created (UTC). Stamped by Persistord's TimestampInterceptor. public DateTimeOffset CreatedAt { get; set; } + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs index 5d1a2dd9..9d609b45 100644 --- a/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs @@ -1,14 +1,13 @@ +using Persistord.Core.Abstractions; + namespace RustPlusBot.Domain.Workspace; /// An anchored bot message, edited in place rather than re-posted. -public sealed class ProvisionedMessage +public sealed class ProvisionedMessage : IGuildScoped, ICreatedAt, IUpdatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - /// The server this message belongs to, or null for a global message. public Guid? RustServerId { get; set; } @@ -21,9 +20,12 @@ public sealed class ProvisionedMessage /// The anchored message snowflake. public ulong DiscordMessageId { get; set; } - /// When the record was first created (UTC). + /// When the record was first created (UTC). Stamped by Persistord's TimestampInterceptor. public DateTimeOffset CreatedAt { get; set; } - /// When the message was last edited in place (UTC). + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } + + /// When the record was last written (UTC). Stamped by Persistord's TimestampInterceptor. public DateTimeOffset UpdatedAt { get; set; } } diff --git a/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs b/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs index c35a4b16..af6d4dd6 100644 --- a/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs +++ b/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs @@ -69,7 +69,8 @@ public async Task RelayAsync(RelayedChatLine line, CancellationToken cancellatio return; } - await poster.PostAsync(line.Kind, channelId.Value, line.SenderName, line.Message, cancellationToken) + await poster.PostAsync(line.Kind, line.GuildId, channelId.Value, line.SenderName, line.Message, + cancellationToken) .ConfigureAwait(false); } diff --git a/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs b/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs index d892dc02..88f09667 100644 --- a/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs +++ b/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs @@ -2,20 +2,24 @@ using Discord; using Discord.Webhook; using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using RustPlusBot.Abstractions.Chat; +using RustPlusBot.Persistence.Chat; namespace RustPlusBot.Features.Chat.Webhooks; /// -/// Real . Ensures one webhook per (channel kind, channel), named after the -/// kind (created if missing, re-discovered by name on restart) and caches the webhook client per -/// (kind, channel). Untested integration shim. +/// Real . Ensures one webhook per (channel kind, channel) and caches the +/// webhook client per (kind, channel). The webhook it creates is recorded in the database, so a restart +/// reuses the webhook the bot already owns instead of hunting for one by name. Untested integration shim. /// /// The Discord socket client. +/// Opens a scope to reach the scoped . /// The logger. internal sealed partial class DiscordChatWebhookPoster( DiscordSocketClient client, + IServiceScopeFactory scopeFactory, ILogger logger) : IChatWebhookPoster, IAsyncDisposable { @@ -36,6 +40,7 @@ public ValueTask DisposeAsync() /// public async Task PostAsync( ChatChannelKind kind, + ulong guildId, ulong channelId, string username, string message, @@ -43,7 +48,8 @@ public async Task PostAsync( { try { - var webhook = await GetOrCreateClientAsync(kind, channelId).ConfigureAwait(false); + var webhook = await GetOrCreateClientAsync(kind, guildId, channelId, cancellationToken) + .ConfigureAwait(false); if (webhook is null) { return; @@ -61,9 +67,10 @@ await webhook.SendMessageAsync(message, username: username, allowedMentions: All } /// - /// Webhook name per channel kind. These strings are load-bearing: the poster re-discovers its - /// webhook by name on restart, so changing one orphans every webhook already created in live - /// guilds and silently creates a duplicate alongside it. + /// Webhook name per channel kind, and the key its record is stored under. The name is only a + /// fallback now that the webhook is recorded — a guild that predates the record still has its + /// webhook found by name once, and re-recorded — but changing one of these strings still orphans + /// every unrecorded webhook already created in a live guild. /// /// The channel kind. /// The webhook name to find or create. @@ -73,23 +80,100 @@ await webhook.SendMessageAsync(message, username: username, allowedMentions: All _ => "RustPlusBot TeamChat", }; - private async Task GetOrCreateClientAsync(ChatChannelKind kind, ulong channelId) + private async Task GetOrCreateClientAsync( + ChatChannelKind kind, + ulong guildId, + ulong channelId, + CancellationToken cancellationToken) { if (_clients.TryGetValue((kind, channelId), out var cached)) { return cached; } + var name = WebhookNameFor(kind); + + var recorded = await FromRecordAsync(kind, guildId, channelId, name, cancellationToken) + .ConfigureAwait(false); + if (recorded is not null) + { + return recorded; + } + if (await client.GetChannelAsync(channelId).ConfigureAwait(false) is not ITextChannel channel) { return null; } - var name = WebhookNameFor(kind); + // No record yet (or the recorded one was unusable): fall back to the name lookup, which is what + // keeps a guild provisioned by an older build from getting a second webhook, then record what we + // end up with so this is the last time this channel is searched. var hooks = await channel.GetWebhooksAsync().ConfigureAwait(false); var hook = hooks.FirstOrDefault(h => string.Equals(h.Name, name, StringComparison.Ordinal)) ?? await channel.CreateWebhookAsync(name).ConfigureAwait(false); - var webhookClient = new DiscordWebhookClient(hook); + + await SaveRecordAsync(guildId, channelId, name, hook, cancellationToken).ConfigureAwait(false); + return Cache(kind, channelId, new DiscordWebhookClient(hook)); + } + + private async Task FromRecordAsync( + ChatChannelKind kind, + ulong guildId, + ulong channelId, + string name, + CancellationToken cancellationToken) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + var record = await store.GetAsync(guildId, channelId, name, cancellationToken).ConfigureAwait(false); + if (record is null) + { + return null; + } + + try + { + return Cache(kind, channelId, new DiscordWebhookClient(record.Value.Id, record.Value.Token)); + } +#pragma warning disable CA1031 // Broad catch: any failure here means the record is unusable, whatever it was. + catch (Exception ex) +#pragma warning restore CA1031 + { + // The recorded webhook is gone or its token was revoked — the constructor validates it + // against Discord. Forget it so the lookup below can adopt or create a replacement, + // rather than failing every line from now on. + LogRecordUnusable(logger, ex, kind, channelId); + await store.ForgetAsync(guildId, channelId, name, cancellationToken).ConfigureAwait(false); + return null; + } + } + } + + private async Task SaveRecordAsync( + ulong guildId, + ulong channelId, + string name, + IWebhook hook, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(hook.Token)) + { + return; // Nothing worth recording: without the token the record could not be posted through. + } + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + await store.SaveAsync(guildId, channelId, name, hook.Id, hook.Token, cancellationToken) + .ConfigureAwait(false); + } + } + + private DiscordWebhookClient Cache(ChatChannelKind kind, ulong channelId, DiscordWebhookClient webhookClient) + { var stored = _clients.GetOrAdd((kind, channelId), webhookClient); if (!ReferenceEquals(stored, webhookClient)) { @@ -106,4 +190,12 @@ private static partial void LogPostFailed( Exception exception, ChatChannelKind kind, ulong channelId); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "The recorded {Kind} webhook for channel {ChannelId} is unusable; re-resolving it.")] + private static partial void LogRecordUnusable( + ILogger logger, + Exception exception, + ChatChannelKind kind, + ulong channelId); } diff --git a/src/RustPlusBot.Features.Chat/Webhooks/IChatWebhookPoster.cs b/src/RustPlusBot.Features.Chat/Webhooks/IChatWebhookPoster.cs index fa64255b..61b7feb8 100644 --- a/src/RustPlusBot.Features.Chat/Webhooks/IChatWebhookPoster.cs +++ b/src/RustPlusBot.Features.Chat/Webhooks/IChatWebhookPoster.cs @@ -7,6 +7,7 @@ public interface IChatWebhookPoster { /// Posts to channel impersonating . /// The in-game chat channel the line came from. + /// The owning guild, which the webhook record is scoped to. /// The target Discord channel snowflake. /// The webhook display name (the in-game player's Steam name). /// The message text. @@ -14,6 +15,7 @@ public interface IChatWebhookPoster /// A task that completes when the message has been posted. Task PostAsync( ChatChannelKind kind, + ulong guildId, ulong channelId, string username, string message, diff --git a/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs b/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs index d4dd2761..95602cb7 100644 --- a/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs +++ b/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Persistord.Core; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Workspace.Reconciler; using RustPlusBot.Persistence; @@ -8,7 +8,7 @@ namespace RustPlusBot.Features.Workspace.Teardown; /// Purges a guild: tears down provisioned channels, then deletes its domain rows. /// The bot database context. -/// Server management (RemoveAsync cascades all per-server rows). +/// Server lookup, to know which connection loops to stop. /// Removes provisioned Discord channels/categories/messages. /// Held across the whole purge to block concurrent reconciliation. /// Stops each server's connection loop before its row is deleted. @@ -31,28 +31,26 @@ public async Task PurgeGuildAsync(ulong guildId, CancellationToken cancellationT // lock-free core since we already hold the lock (ResetGuildAsync would deadlock re-acquiring). await teardown.ResetGuildCoreAsync(guildId, cancellationToken).ConfigureAwait(false); - // 2) Remove each server; the RustServer FK cascade clears its per-server rows - // (connection state, command/map settings, switches, alarms, storage monitors, credentials). - // Stop the socket BEFORE each row delete, exactly as ServerRemovalService does for a single - // server: a connection loop still running when its RustServer row goes away faults on the - // connection-state foreign key at its next status write, and leaks its socket for the life of - // the process. StopAsync joins the loop, so it is finished before the delete lands. + // 2) Stop every connection loop BEFORE any row is deleted. A loop still running when its + // RustServer row goes away faults on the connection-state foreign key at its next status + // write, and leaks its socket for the life of the process. StopAsync joins the loop, so it + // is finished before the deletes land. var known = await servers.ListAsync(guildId, cancellationToken).ConfigureAwait(false); foreach (var serverId in known.Select(server => server.Id)) { await connections.StopAsync(guildId, serverId).ConfigureAwait(false); - await servers.RemoveAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); } - // 3) Delete guild-keyed rows that have no cascade FK to RustServer (event subscriptions, - // paired entities, guild settings, FCM registrations). - await context.EventSubscriptions.Where(e => e.GuildId == guildId) - .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); - await context.PairedEntities.Where(p => p.GuildId == guildId) - .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); - await context.GuildSettings.Where(g => g.GuildId == guildId) - .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); - await context.FcmRegistrations.Where(f => f.GuildId == guildId) - .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + // 3) Delete every guild-scoped row in one transaction. Persistord walks the model for + // IGuildScoped entity types and deletes dependents before principals, so this covers both + // what used to cascade off RustServer and what never had a foreign key to it (guild + // settings, FCM registrations) — and a new guild-scoped table joins it by declaring the + // interface, rather than by someone remembering to add a line here. + await context.PurgeGuildAsync(guildId, cancellationToken).ConfigureAwait(false); + + // The deletes run as SQL and leave the change tracker holding rows that no longer exist — + // the server list above tracked some of them. The next SaveChanges on this scoped context + // would try to flush them. + context.ChangeTracker.Clear(); } } diff --git a/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs b/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs index bbada0c1..4639b68b 100644 --- a/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs +++ b/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs @@ -1,55 +1,39 @@ using Microsoft.EntityFrameworkCore; +using Persistord.Core; using RustPlusBot.Abstractions.Connections; -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Alarms; namespace RustPlusBot.Persistence.Alarms; /// EF-backed . /// The bot database context. -/// Supplies the creation timestamp. -public sealed class AlarmStore(BotDbContext context, IClock clock) : IAlarmStore +public sealed class AlarmStore(BotDbContext context) : IAlarmStore { /// - public async Task AddAsync( + public Task AddAsync( ulong guildId, Guid serverId, ulong entityId, string name, ulong pairedByUserId, - CancellationToken ct = default) - { - var entity = new SmartAlarm - { - GuildId = guildId, - ServerId = serverId, - EntityId = entityId, - Name = name, - PairedByUserId = pairedByUserId, - CreatedUtc = clock.UtcNow, - }; - context.SmartAlarms.Add(entity); - try - { - await context.SaveChangesAsync(ct).ConfigureAwait(false); - return entity; - } - catch (DbUpdateException) - { - // Two users accepted the same pending pairing concurrently (both saw ExistsAsync == false); the - // unique (GuildId, ServerId, EntityId) index rejects the second insert. Recover idempotently by - // detaching the failed insert and returning the row the winner persisted. If no such row exists, - // the failure was not the uniqueness race — let it propagate. - context.Entry(entity).State = EntityState.Detached; - var existing = await GetAsync(guildId, serverId, entityId, ct).ConfigureAwait(false); - if (existing is null) + CancellationToken ct = default) => + // Adding is idempotent: two users can accept the same pending pairing concurrently, and the + // unique (GuildId, ServerId, EntityId) index rejects whichever insert lands second. The empty + // mutation is deliberate — the row the winner wrote is returned untouched, name and all — + // and Persistord recovers the race by re-reading that winner. CreatedAt is stamped by the + // TimestampInterceptor. + context.SmartAlarms.UpsertAsync( + a => a.GuildId == guildId && a.ServerId == serverId && a.EntityId == entityId, + () => new SmartAlarm { - throw; - } - - return existing; - } - } + GuildId = guildId, + ServerId = serverId, + EntityId = entityId, + Name = name, + PairedByUserId = pairedByUserId, + }, + _ => { }, + ct); /// public Task GetAsync( @@ -72,7 +56,7 @@ public async Task> ListByServerAsync( .ToListAsync(ct) .ConfigureAwait(false); - return [.. alarms.OrderBy(a => a.CreatedUtc)]; + return [.. alarms.OrderBy(a => a.CreatedAt)]; } /// diff --git a/src/RustPlusBot.Persistence/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs index 51333237..f00acab4 100644 --- a/src/RustPlusBot.Persistence/BotDbContext.cs +++ b/src/RustPlusBot.Persistence/BotDbContext.cs @@ -1,13 +1,13 @@ using Microsoft.EntityFrameworkCore; using Persistord.Core; +using Persistord.Managed.Configurations; +using Persistord.Managed.Entities; using RustPlusBot.Domain.Alarms; using RustPlusBot.Domain.Clans; using RustPlusBot.Domain.Commands; using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; using RustPlusBot.Domain.Devices; -using RustPlusBot.Domain.Entities; -using RustPlusBot.Domain.Events; using RustPlusBot.Domain.Guilds; using RustPlusBot.Domain.Map; using RustPlusBot.Domain.Servers; @@ -20,8 +20,10 @@ namespace RustPlusBot.Persistence; /// -/// The bot's EF Core context. Inherits Persistord's DiscordDbContext for the Discord skeleton and -/// the global ulong<->long snowflake conversion, and adds the Rust-domain sets. +/// The bot's EF Core context. Inherits Persistord's DiscordDbContext for the global +/// ulong<->long snowflake conversion and the snowflake-key convention, and adds the Rust-domain +/// sets. Deliberately not DiscordGraphDbContext: the bot owns its Discord resources rather than +/// mirroring Discord's graph, so it maps none of the guild/channel/user/member/role skeleton. /// /// The EF Core options, typically configured with a specific provider (e.g. SQLite, PostgreSQL). public sealed class BotDbContext(DbContextOptions options) : DiscordDbContext(options) @@ -47,9 +49,6 @@ public sealed class BotDbContext(DbContextOptions options) : Disco /// Per-guild settings. public DbSet GuildSettings => Set(); - /// Paired smart devices. - public DbSet PairedEntities => Set(); - /// Paired and managed Smart Switches. public DbSet SmartSwitches => Set(); @@ -59,9 +58,6 @@ public sealed class BotDbContext(DbContextOptions options) : Disco /// Managed Smart Storage Monitors. public DbSet SmartStorageMonitors => Set(); - /// Per-guild event subscriptions. - public DbSet EventSubscriptions => Set(); - /// Provisioned Discord categories (global + per-server). public DbSet ProvisionedCategories => Set(); @@ -89,11 +85,14 @@ public sealed class BotDbContext(DbContextOptions options) : Disco /// Live sell-out notifications. public DbSet VendingStockNotifications => Set(); + /// Chat-relay webhooks the bot created, remembered so they are never re-discovered by name. + public DbSet ChatWebhooks => Set(); + /// protected override void OnModelCreating(ModelBuilder modelBuilder) { ArgumentNullException.ThrowIfNull(modelBuilder); - base.OnModelCreating(modelBuilder); // core skeleton + snowflake convention + base.OnModelCreating(modelBuilder); // PairedDeviceEntity is a code-sharing base, not an entity type: SmartSwitch and // SmartStorageMonitor each own their table. Ignoring it makes that intent EF-enforced — without @@ -108,11 +107,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .ApplyConfiguration(new ServerCommandSettingsConfiguration()) .ApplyConfiguration(new ServerMapSettingsConfiguration()) .ApplyConfiguration(new GuildSettingsConfiguration()) - .ApplyConfiguration(new PairedEntityConfiguration()) .ApplyConfiguration(new SmartSwitchConfiguration()) .ApplyConfiguration(new SmartAlarmConfiguration()) .ApplyConfiguration(new SmartStorageMonitorConfiguration()) - .ApplyConfiguration(new EventSubscriptionConfiguration()) .ApplyConfiguration(new ProvisionedCategoryConfiguration()) .ApplyConfiguration(new ProvisionedChannelConfiguration()) .ApplyConfiguration(new ProvisionedMessageConfiguration()) @@ -121,6 +118,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .ApplyConfiguration(new VendingGridTrackConfiguration()) .ApplyConfiguration(new VendingListingTrackConfiguration()) .ApplyConfiguration(new VendingNotificationConfiguration()) - .ApplyConfiguration(new VendingStockNotificationConfiguration()); + .ApplyConfiguration(new VendingStockNotificationConfiguration()) + // Only the webhook resource, not ApplyManagedModule: that maps all four managed types, and + // the bot owns its categories, channels and anchored messages through its own Provisioned* + // tables, which key their scope by a real foreign key to RustServers rather than by + // ManagedResource's opaque string. Mapping the other three would add three empty tables. + .ApplyConfiguration(new ManagedWebhookConfiguration()); } } diff --git a/src/RustPlusBot.Persistence/Chat/ChatWebhookStore.cs b/src/RustPlusBot.Persistence/Chat/ChatWebhookStore.cs new file mode 100644 index 00000000..ebdb1c6a --- /dev/null +++ b/src/RustPlusBot.Persistence/Chat/ChatWebhookStore.cs @@ -0,0 +1,104 @@ +using System.Globalization; +using System.Security.Cryptography; +using Microsoft.EntityFrameworkCore; +using Persistord.Managed; +using Persistord.Managed.Entities; +using RustPlusBot.Abstractions.Credentials; + +namespace RustPlusBot.Persistence.Chat; + +/// +/// EF-backed over Persistord's records, +/// scoped by channel so one channel can hold a webhook per chat kind. +/// +/// The bot database context. +/// Protects the webhook token before it is written. +public sealed class ChatWebhookStore(BotDbContext context, ICredentialProtector protector) : IChatWebhookStore +{ + /// + public async Task GetAsync( + ulong guildId, + ulong channelId, + string key, + CancellationToken cancellationToken = default) + { + var record = await context.FindManagedAsync(guildId, Scope(channelId), key, + cancellationToken) + .ConfigureAwait(false); + if (record is null) + { + return null; + } + + try + { + return new ChatWebhook(record.DiscordId, protector.Unprotect(record.Token)); + } + catch (CryptographicException) + { + // The Data Protection key ring that wrote this token can no longer read it (a rebuilt + // container with no persisted keys, say). The record is dead weight: drop it and report + // "none", so the caller re-discovers the webhook and records a readable token. + await ForgetAsync(guildId, channelId, key, cancellationToken).ConfigureAwait(false); + return null; + } + } + + /// + public async Task SaveAsync( + ulong guildId, + ulong channelId, + string key, + ulong webhookId, + string token, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(token); + + // The token is written already protected, by the bot's own ICredentialProtector — the same one + // that covers player and FCM credentials. Persistord's [Protected] attribute on this column is + // inert unless Persistord.Protection is referenced and wired; if it ever is, this call has to + // go at the same time or every token gets encrypted twice. + await context.UpsertManagedAsync( + guildId, + Scope(channelId), + key, + webhookId, + record => + { + record.ChannelDiscordId = channelId; + record.Token = protector.Protect(token); + }, + cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task ForgetAsync( + ulong guildId, + ulong channelId, + string key, + CancellationToken cancellationToken = default) + { + var scope = Scope(channelId); + await context.ChatWebhooks + .Where(w => w.GuildId == guildId && w.Scope == scope && w.Key == key) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + // ExecuteDelete bypasses the change tracker; a record read earlier in this scope would linger. + foreach (var entry in context.ChangeTracker.Entries() + .Where(e => e.Entity.GuildId == guildId && e.Entity.Key == key + && string.Equals(e.Entity.Scope, scope, + StringComparison.Ordinal)) + .ToList()) + { + entry.State = EntityState.Detached; + } + } + + /// The managed scope a channel's webhooks live in: the channel id, as an invariant string. + /// The channel snowflake. + /// The scope value. + private static string Scope(ulong channelId) => channelId.ToString(CultureInfo.InvariantCulture); +} diff --git a/src/RustPlusBot.Persistence/Chat/IChatWebhookStore.cs b/src/RustPlusBot.Persistence/Chat/IChatWebhookStore.cs new file mode 100644 index 00000000..ba5527eb --- /dev/null +++ b/src/RustPlusBot.Persistence/Chat/IChatWebhookStore.cs @@ -0,0 +1,57 @@ +namespace RustPlusBot.Persistence.Chat; + +/// A chat-relay webhook the bot created: enough to post through it without asking Discord. +/// The webhook snowflake. +/// The webhook token, in plaintext. +public readonly record struct ChatWebhook(ulong Id, string Token); + +/// +/// Remembers the webhooks the chat relay creates, so a restart posts through the webhook it already +/// owns instead of re-discovering one by name — a rename would otherwise orphan the old webhook and +/// silently create a duplicate alongside it. +/// +public interface IChatWebhookStore +{ + /// Reads the webhook recorded for a channel, or null when there is none. + /// The owning guild. + /// The channel the webhook posts to. + /// The relay's stable key for the webhook (the chat kind). + /// A cancellation token. + /// The webhook, or null. + Task GetAsync( + ulong guildId, + ulong channelId, + string key, + CancellationToken cancellationToken = default); + + /// Records a webhook, replacing whatever was recorded for the same channel and key. + /// The owning guild. + /// The channel the webhook posts to. + /// The relay's stable key for the webhook (the chat kind). + /// The webhook snowflake Discord returned. + /// The webhook token, in plaintext; it is protected before it is written. + /// A cancellation token. + /// A task that completes when the record has been written. + Task SaveAsync( + ulong guildId, + ulong channelId, + string key, + ulong webhookId, + string token, + CancellationToken cancellationToken = default); + + /// + /// Drops the record for a channel, so the next post re-discovers or re-creates the webhook. Call it + /// when the recorded webhook turns out to be unusable — deleted in Discord, or its token revoked. + /// + /// The owning guild. + /// The channel the webhook posts to. + /// The relay's stable key for the webhook (the chat kind). + /// A cancellation token. + /// A task that completes when the record is gone. + Task ForgetAsync( + ulong guildId, + ulong channelId, + string key, + CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Persistence/Clans/ClanStore.cs b/src/RustPlusBot.Persistence/Clans/ClanStore.cs index b32c9500..24ba8b0d 100644 --- a/src/RustPlusBot.Persistence/Clans/ClanStore.cs +++ b/src/RustPlusBot.Persistence/Clans/ClanStore.cs @@ -1,14 +1,14 @@ using Microsoft.EntityFrameworkCore; +using Persistord.Core; using RustPlusBot.Abstractions.Connections; -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Clans; namespace RustPlusBot.Persistence.Clans; /// EF-backed . /// The bot database context. -/// Supplies write timestamps. -internal sealed class ClanStore(BotDbContext db, IClock clock) : IClanStore +/// Supplies the last-seen timestamp; the same clock the TimestampInterceptor uses. +internal sealed class ClanStore(BotDbContext db, TimeProvider timeProvider) : IClanStore { /// public async Task GetAsync( @@ -52,37 +52,15 @@ public async Task SaveAsync( { ArgumentNullException.ThrowIfNull(snapshot); - var row = await db.ClanStates - .FirstOrDefaultAsync(s => s.ServerId == serverId, cancellationToken) + await db.ClanStates.UpsertAsync( + s => s.ServerId == serverId, + () => new ClanState + { + ServerId = serverId + }, + row => Apply(row, guildId, snapshot, timeProvider.GetUtcNow()), + cancellationToken) .ConfigureAwait(false); - - if (row is null) - { - row = new ClanState - { - ServerId = serverId - }; - db.ClanStates.Add(row); - } - - row.GuildId = guildId; - row.ClanId = snapshot.ClanId; - row.Name = snapshot.Name; - row.Created = snapshot.Created; - row.Creator = snapshot.Creator; - row.Motd = snapshot.Motd; - row.MotdTimestamp = snapshot.MotdTimestamp; - row.MotdAuthor = snapshot.MotdAuthor; - row.LogoHash = snapshot.LogoHash; - row.Color = snapshot.Color; - row.MaxMemberCount = snapshot.MaxMemberCount; - row.Score = snapshot.Score; - row.RolesJson = ClanSnapshotSerializer.Serialize(snapshot.Roles); - row.MembersJson = ClanSnapshotSerializer.Serialize(snapshot.Members); - row.InvitesJson = ClanSnapshotSerializer.Serialize(snapshot.Invites); - row.LastSeenUtc = clock.UtcNow; - - await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } /// @@ -143,30 +121,40 @@ public async Task RecordNameAsync( { ArgumentNullException.ThrowIfNull(name); - var row = await db.ClanPlayerNames - .FirstOrDefaultAsync( + // Writing the same name again is the common case (every chat line repeats it). The upsert + // saves only when something actually changed, so an unchanged row keeps its UpdatedAt. + await db.ClanPlayerNames.UpsertAsync( n => n.ServerId == serverId && n.SteamId == steamId, + () => new ClanPlayerName + { + ServerId = serverId, SteamId = steamId + }, + row => + { + row.GuildId = guildId; + row.Name = name; + }, cancellationToken) .ConfigureAwait(false); + } - if (row is not null && row.GuildId == guildId && string.Equals(row.Name, name, StringComparison.Ordinal)) - { - return; - } - - if (row is null) - { - row = new ClanPlayerName - { - ServerId = serverId, SteamId = steamId - }; - db.ClanPlayerNames.Add(row); - } - + private static void Apply(ClanState row, ulong guildId, ClanSnapshot snapshot, DateTimeOffset seenAt) + { row.GuildId = guildId; - row.Name = name; - row.UpdatedUtc = clock.UtcNow; - - await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + row.ClanId = snapshot.ClanId; + row.Name = snapshot.Name; + row.Created = snapshot.Created; + row.Creator = snapshot.Creator; + row.Motd = snapshot.Motd; + row.MotdTimestamp = snapshot.MotdTimestamp; + row.MotdAuthor = snapshot.MotdAuthor; + row.LogoHash = snapshot.LogoHash; + row.Color = snapshot.Color; + row.MaxMemberCount = snapshot.MaxMemberCount; + row.Score = snapshot.Score; + row.RolesJson = ClanSnapshotSerializer.Serialize(snapshot.Roles); + row.MembersJson = ClanSnapshotSerializer.Serialize(snapshot.Members); + row.InvitesJson = ClanSnapshotSerializer.Serialize(snapshot.Invites); + row.LastSeenUtc = seenAt; } } diff --git a/src/RustPlusBot.Persistence/Commands/MuteStore.cs b/src/RustPlusBot.Persistence/Commands/MuteStore.cs index 052766e7..fc921fe8 100644 --- a/src/RustPlusBot.Persistence/Commands/MuteStore.cs +++ b/src/RustPlusBot.Persistence/Commands/MuteStore.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Persistord.Core; using RustPlusBot.Domain.Commands; namespace RustPlusBot.Persistence.Commands; @@ -20,27 +21,17 @@ public async Task GetMutedAsync(ulong guildId, Guid serverId, Cancellation public async Task SetMutedAsync(ulong guildId, Guid serverId, bool muted, - CancellationToken cancellationToken = default) - { - var existing = await context.ServerCommandSettings - .SingleOrDefaultAsync(s => s.GuildId == guildId && s.ServerId == serverId, cancellationToken) + CancellationToken cancellationToken = default) => + await context.ServerCommandSettings.UpsertAsync( + s => s.GuildId == guildId && s.ServerId == serverId, + () => new ServerCommandSettings + { + GuildId = guildId, ServerId = serverId + }, + row => row.Muted = muted, + cancellationToken) .ConfigureAwait(false); - if (existing is null) - { - context.ServerCommandSettings.Add(new ServerCommandSettings - { - GuildId = guildId, ServerId = serverId, Muted = muted, - }); - } - else - { - existing.Muted = muted; - } - - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } - /// public async Task GetPrefixAsync(ulong guildId, Guid serverId, diff --git a/src/RustPlusBot.Persistence/Configurations/EventSubscriptionConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/EventSubscriptionConfiguration.cs deleted file mode 100644 index d7441aa7..00000000 --- a/src/RustPlusBot.Persistence/Configurations/EventSubscriptionConfiguration.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using RustPlusBot.Domain.Events; - -namespace RustPlusBot.Persistence.Configurations; - -internal sealed class EventSubscriptionConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - builder.HasKey(e => e.Id); - builder.HasIndex(e => new - { - e.GuildId, e.RustServerId - }); - builder.Property(e => e.EventKey).IsRequired().HasMaxLength(64); - } -} diff --git a/src/RustPlusBot.Persistence/Configurations/GuildSettingsConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/GuildSettingsConfiguration.cs index cf9ed8aa..0cead0bd 100644 --- a/src/RustPlusBot.Persistence/Configurations/GuildSettingsConfiguration.cs +++ b/src/RustPlusBot.Persistence/Configurations/GuildSettingsConfiguration.cs @@ -9,8 +9,8 @@ internal sealed class GuildSettingsConfiguration : IEntityTypeConfiguration builder) { ArgumentNullException.ThrowIfNull(builder); + // Persistord's SnowflakeKeyConvention already marks every ulong key caller-supplied. builder.HasKey(s => s.GuildId); - builder.Property(s => s.GuildId).ValueGeneratedNever(); builder.Property(s => s.Culture).IsRequired().HasMaxLength(16); } } diff --git a/src/RustPlusBot.Persistence/Configurations/PairedEntityConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/PairedEntityConfiguration.cs deleted file mode 100644 index c672e55f..00000000 --- a/src/RustPlusBot.Persistence/Configurations/PairedEntityConfiguration.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using RustPlusBot.Domain.Entities; - -namespace RustPlusBot.Persistence.Configurations; - -internal sealed class PairedEntityConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - builder.HasKey(e => e.Id); - builder.HasIndex(e => new - { - e.GuildId, e.RustServerId - }); - builder.Property(e => e.Name).IsRequired().HasMaxLength(128); - } -} diff --git a/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs b/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs index eda066be..3946b370 100644 --- a/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs +++ b/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; @@ -7,8 +6,7 @@ namespace RustPlusBot.Persistence.Connections; /// EF-backed . /// The bot database context. -/// Supplies the update timestamp. -public sealed class ConnectionStore(BotDbContext context, IClock clock) : IConnectionStore +public sealed class ConnectionStore(BotDbContext context) : IConnectionStore { /// public Task GetStateAsync( @@ -58,7 +56,6 @@ public async Task UpsertStatusAsync( Status = status, PlayerCount = playerCount, ActiveCredentialId = activeCredentialId, - UpdatedAt = clock.UtcNow, }); try @@ -93,7 +90,6 @@ public async Task UpsertStatusAsync( existing.Status = status; existing.PlayerCount = playerCount; existing.ActiveCredentialId = activeCredentialId; - existing.UpdatedAt = clock.UtcNow; await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return true; diff --git a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs index f1cb19ba..25e36ac2 100644 --- a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs +++ b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Persistord.Core; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Domain.Credentials; @@ -17,39 +18,31 @@ public async Task UpsertFromPairingAsync( { ArgumentNullException.ThrowIfNull(request); - var existing = await context.PlayerCredentials - .SingleOrDefaultAsync( + // A resubmitted token revives an Invalid credential as Standby rather than promoting it: only + // a brand-new credential honours markActive, which is why that lives in the create branch. + var credential = await context.PlayerCredentials.UpsertAsync( c => c.GuildId == request.GuildId && c.RustServerId == request.RustServerId && c.OwnerUserId == request.OwnerUserId, + () => new PlayerCredential + { + GuildId = request.GuildId, + RustServerId = request.RustServerId, + OwnerUserId = request.OwnerUserId, + Status = markActive ? CredentialStatus.Active : CredentialStatus.Standby, + }, + row => + { + row.SteamId = request.SteamId; + row.ProtectedPlayerToken = protector.Protect(request.PlayerToken); + if (row.Status == CredentialStatus.Invalid) + { + row.Status = CredentialStatus.Standby; + } + }, cancellationToken) .ConfigureAwait(false); - if (existing is not null) - { - existing.SteamId = request.SteamId; - existing.ProtectedPlayerToken = protector.Protect(request.PlayerToken); - if (existing.Status == CredentialStatus.Invalid) - { - existing.Status = CredentialStatus.Standby; - } - - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return existing.Id; - } - - var credential = new PlayerCredential - { - GuildId = request.GuildId, - RustServerId = request.RustServerId, - OwnerUserId = request.OwnerUserId, - SteamId = request.SteamId, - ProtectedPlayerToken = protector.Protect(request.PlayerToken), - Status = markActive ? CredentialStatus.Active : CredentialStatus.Standby, - }; - - context.PlayerCredentials.Add(credential); - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return credential.Id; } diff --git a/src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs b/src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs index 5b4fec39..eb55a840 100644 --- a/src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs +++ b/src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs @@ -1,6 +1,6 @@ using Microsoft.EntityFrameworkCore; +using Persistord.Core; using RustPlusBot.Abstractions.Credentials; -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Credentials; namespace RustPlusBot.Persistence.Credentials; @@ -8,8 +8,7 @@ namespace RustPlusBot.Persistence.Credentials; /// EF-backed that protects credentials before persisting. /// The bot database context. /// Protects credential material before it is written. -/// Supplies the update timestamp. -public sealed class FcmRegistrationStore(BotDbContext context, ICredentialProtector protector, IClock clock) +public sealed class FcmRegistrationStore(BotDbContext context, ICredentialProtector protector) : IFcmRegistrationStore { /// @@ -21,47 +20,23 @@ public async Task UpsertAsync( { ArgumentNullException.ThrowIfNull(fcmCredentialsJson); - var existing = await context.FcmRegistrations - .SingleOrDefaultAsync(r => r.GuildId == guildId && r.OwnerUserId == ownerUserId, cancellationToken) + // Persistord's upsert owns the (guild, owner) unique-index race: it re-reads the winner once + // and applies the same mutation to it. UpdatedAt is stamped by the TimestampInterceptor. + var registration = await context.FcmRegistrations.UpsertAsync( + r => r.GuildId == guildId && r.OwnerUserId == ownerUserId, + () => new FcmRegistration + { + GuildId = guildId, OwnerUserId = ownerUserId + }, + row => + { + row.ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson); + row.Status = FcmRegistrationStatus.Active; + }, + cancellationToken) .ConfigureAwait(false); - if (existing is not null) - { - existing.ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson); - existing.Status = FcmRegistrationStatus.Active; - existing.UpdatedAt = clock.UtcNow; - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return existing.Id; - } - - var registration = new FcmRegistration - { - GuildId = guildId, - OwnerUserId = ownerUserId, - ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson), - Status = FcmRegistrationStatus.Active, - UpdatedAt = clock.UtcNow, - }; - - context.FcmRegistrations.Add(registration); - try - { - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return registration.Id; - } - catch (DbUpdateException) - { - // A concurrent submission for the same (guild, owner) won the unique-index race; update the winner. - context.Entry(registration).State = EntityState.Detached; - var winner = await context.FcmRegistrations - .SingleAsync(r => r.GuildId == guildId && r.OwnerUserId == ownerUserId, cancellationToken) - .ConfigureAwait(false); - winner.ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson); - winner.Status = FcmRegistrationStatus.Active; - winner.UpdatedAt = clock.UtcNow; - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return winner.Id; - } + return registration.Id; } /// @@ -86,7 +61,6 @@ public async Task SetStatusAsync( } registration.Status = status; - registration.UpdatedAt = clock.UtcNow; await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } diff --git a/src/RustPlusBot.Persistence/Devices/PairedDeviceStore.cs b/src/RustPlusBot.Persistence/Devices/PairedDeviceStore.cs index 1ea04936..59311758 100644 --- a/src/RustPlusBot.Persistence/Devices/PairedDeviceStore.cs +++ b/src/RustPlusBot.Persistence/Devices/PairedDeviceStore.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; +using Persistord.Core; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Devices; -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Devices; namespace RustPlusBot.Persistence.Devices; @@ -13,53 +13,37 @@ namespace RustPlusBot.Persistence.Devices; /// /// The persisted device row. /// The bot database context. -/// Supplies the creation timestamp. -public abstract class PairedDeviceStore(BotDbContext context, IClock clock) : IPairedDeviceStore +public abstract class PairedDeviceStore(BotDbContext context) : IPairedDeviceStore where TEntity : PairedDeviceEntity, new() { /// The device's table. private DbSet Set => context.Set(); /// - public async Task AddAsync( + public Task AddAsync( ulong guildId, Guid serverId, ulong entityId, string name, ulong pairedByUserId, - CancellationToken cancellationToken = default) - { - var entity = new TEntity - { - GuildId = guildId, - ServerId = serverId, - EntityId = entityId, - Name = name, - PairedByUserId = pairedByUserId, - CreatedUtc = clock.UtcNow, - }; - Set.Add(entity); - try - { - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return entity; - } - catch (DbUpdateException) - { - // Two users accepted the same pending pairing concurrently (both saw ExistsAsync == false); the - // unique (GuildId, ServerId, EntityId) index rejects the second insert. Recover idempotently by - // detaching the failed insert and returning the row the winner persisted. If no such row exists, - // the failure was not the uniqueness race — let it propagate. - context.Entry(entity).State = EntityState.Detached; - var existing = await GetAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false); - if (existing is null) + CancellationToken cancellationToken = default) => + // Adding is idempotent: two users can accept the same pending pairing concurrently, and the + // unique (GuildId, ServerId, EntityId) index rejects whichever insert lands second. The empty + // mutation is deliberate — the row the winner wrote is returned untouched, name and all — + // and Persistord recovers the race by re-reading that winner. CreatedAt is stamped by the + // TimestampInterceptor. + Set.UpsertAsync( + s => s.GuildId == guildId && s.ServerId == serverId && s.EntityId == entityId, + () => new TEntity { - throw; - } - - return existing; - } - } + GuildId = guildId, + ServerId = serverId, + EntityId = entityId, + Name = name, + PairedByUserId = pairedByUserId, + }, + _ => { }, + cancellationToken); /// Gets a device by identity, or null. /// Owning Discord guild snowflake. @@ -91,7 +75,7 @@ public async Task> ListByServerAsync( .ToListAsync(cancellationToken) .ConfigureAwait(false); - return [.. devices.OrderBy(s => s.CreatedUtc)]; + return [.. devices.OrderBy(s => s.CreatedAt)]; } /// diff --git a/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs b/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs index 76e52496..662c1a3c 100644 --- a/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs +++ b/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs @@ -1,5 +1,4 @@ -using System.Globalization; -using Microsoft.EntityFrameworkCore; +using Persistord.Core; namespace RustPlusBot.Persistence.Maintenance; @@ -8,45 +7,10 @@ namespace RustPlusBot.Persistence.Maintenance; public sealed class DatabaseMaintenanceService(BotDbContext context) : IDatabaseMaintenanceService { /// - public async Task ClearAllAsync(CancellationToken cancellationToken = default) - { - var tables = context.Model.GetEntityTypes() - .Select(t => t.GetTableName()) - .Where(name => !string.IsNullOrEmpty(name)) - .Distinct(StringComparer.Ordinal) - .ToList(); - - // Wipe every table in one transaction so an interruption rolls back rather than leaving the - // database partially cleared. defer_foreign_keys defers FK enforcement to commit time (and - // resets itself when the transaction ends), so tables can be cleared in any order — once every - // table is empty the commit-time check has nothing to violate. This also avoids leaving a - // connection-level foreign_keys pragma toggled off on a pooled connection. - var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); - await using (transaction.ConfigureAwait(false)) - { - await context.Database.ExecuteSqlRawAsync("PRAGMA defer_foreign_keys = ON", cancellationToken) - .ConfigureAwait(false); - - foreach (var table in tables) - { - // Table names come from the EF model (never user input). Fail loud on an unexpected - // identifier rather than silently skipping it and reporting a misleading success; the - // guard also keeps the raw statement demonstrably injection-safe for the Sonar gate. - if (!IsSafeIdentifier(table!)) - { - throw new InvalidOperationException( - string.Create(CultureInfo.InvariantCulture, - $"Refusing to clear table with an unexpected identifier: '{table}'.")); - } - - var sql = string.Create(CultureInfo.InvariantCulture, $"DELETE FROM \"{table}\""); - await context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false); - } - - await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); - } - } - - private static bool IsSafeIdentifier(string identifier) => - identifier.All(c => char.IsLetterOrDigit(c) || c == '_'); + public Task ClearAllAsync(CancellationToken cancellationToken = default) => + // Persistord deletes every mapped table dependents-first in one transaction, so an + // interruption rolls back rather than leaving the database partially cleared. It issues + // plain DELETEs through EF, which is why this no longer needs the SQLite-only + // defer_foreign_keys pragma nor a raw-SQL identifier guard. + context.ClearAllTablesAsync(cancellationToken); } diff --git a/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs b/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs index d35a6089..26158eb4 100644 --- a/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs +++ b/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Persistord.Core; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Domain.Map; @@ -27,16 +28,34 @@ public async Task SetLayerAsync(ulong guildId, Guid serverId, MapLayer layer, bool enabled, - CancellationToken cancellationToken = default) - { - var existing = await context.ServerMapSettings - .SingleOrDefaultAsync(s => s.GuildId == guildId && s.ServerId == serverId, cancellationToken) + CancellationToken cancellationToken = default) => + await context.ServerMapSettings.UpsertAsync( + s => s.GuildId == guildId && s.ServerId == serverId, + () => new ServerMapSettings + { + GuildId = guildId, ServerId = serverId + }, + row => ApplyLayer(row, layer, enabled), + cancellationToken) .ConfigureAwait(false); - var row = existing ?? new ServerMapSettings - { - GuildId = guildId, ServerId = serverId - }; + /// + public async Task SetGridStyleAsync(ulong guildId, + Guid serverId, + MapGridStyle style, + CancellationToken cancellationToken = default) => + await context.ServerMapSettings.UpsertAsync( + s => s.GuildId == guildId && s.ServerId == serverId, + () => new ServerMapSettings + { + GuildId = guildId, ServerId = serverId + }, + row => row.GridStyle = style, + cancellationToken) + .ConfigureAwait(false); + + private static void ApplyLayer(ServerMapSettings row, MapLayer layer, bool enabled) + { switch (layer) { case MapLayer.Grid: @@ -63,35 +82,5 @@ public async Task SetLayerAsync(ulong guildId, default: throw new ArgumentOutOfRangeException(nameof(layer), layer, "Unknown map layer."); } - - if (existing is null) - { - context.ServerMapSettings.Add(row); - } - - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } - - /// - public async Task SetGridStyleAsync(ulong guildId, - Guid serverId, - MapGridStyle style, - CancellationToken cancellationToken = default) - { - var existing = await context.ServerMapSettings - .SingleOrDefaultAsync(s => s.GuildId == guildId && s.ServerId == serverId, cancellationToken) - .ConfigureAwait(false); - - var row = existing ?? new ServerMapSettings - { - GuildId = guildId, ServerId = serverId - }; - row.GridStyle = style; - if (existing is null) - { - context.ServerMapSettings.Add(row); - } - - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } } diff --git a/src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.Designer.cs new file mode 100644 index 00000000..0c778c32 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.Designer.cs @@ -0,0 +1,878 @@ +// +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("20260910143851_DropMirrorAndDeadTables")] + partial class DropMirrorAndDeadTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTriggeredUtc") + .HasColumnType("TEXT"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("PingEveryone") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("RelayToTeamChat") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartAlarms"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanPlayerName", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("SteamId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedUtc") + .HasColumnType("TEXT"); + + b.HasKey("ServerId", "SteamId"); + + b.ToTable("ClanPlayerNames"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanState", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("ClanId") + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Creator") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("InvitesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LogoHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxMemberCount") + .HasColumnType("INTEGER"); + + b.Property("MembersJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Motd") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("MotdAuthor") + .HasColumnType("INTEGER"); + + b.Property("MotdTimestamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RolesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.HasKey("ServerId"); + + b.ToTable("ClanStates"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.HasKey("ServerId"); + + b.ToTable("ServerCommandSettings"); + }); + + 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.Guilds.GuildSettings", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PingEveryoneOnWipe") + .HasColumnType("INTEGER"); + + b.HasKey("GuildId"); + + b.ToTable("GuildSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("GridStyle") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ShowGrid") + .HasColumnType("INTEGER"); + + b.Property("ShowMarkers") + .HasColumnType("INTEGER"); + + b.Property("ShowMonuments") + .HasColumnType("INTEGER"); + + b.Property("ShowPlayers") + .HasColumnType("INTEGER"); + + b.Property("ShowRigs") + .HasColumnType("INTEGER"); + + b.Property("ShowTunnels") + .HasColumnType("INTEGER"); + + b.Property("ShowVendor") + .HasColumnType("INTEGER"); + + b.HasKey("ServerId"); + + b.ToTable("ServerMapSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedByUserId") + .HasColumnType("INTEGER"); + + b.Property("FacepunchServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Ip") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastMapSeed") + .HasColumnType("INTEGER"); + + b.Property("LastMapSize") + .HasColumnType("INTEGER"); + + b.Property("LastWipeTimeUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("FacepunchServerId"); + + b.HasIndex("GuildId"); + + b.HasIndex("GuildId", "Ip", "Port") + .IsUnique(); + + b.ToTable("RustServers"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartStorageMonitors"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartSwitches"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingGridTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Grid") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RegisteredBySteamId") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "Grid") + .IsUnique(); + + b.ToTable("VendingGridTracks"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingListingTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CostPerOrder") + .HasColumnType("INTEGER"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("CurrencyId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("ItemIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("RegisteredByUserId") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "ItemId", "ItemIsBlueprint", "CurrencyId", "CurrencyIsBlueprint") + .IsUnique(); + + b.ToTable("VendingListingTracks"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CurrencyId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("ItemIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("PostedUtc") + .HasColumnType("TEXT"); + + b.Property("ReferenceCostPerOrder") + .HasColumnType("INTEGER"); + + b.Property("ReferenceQuantity") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "ItemId", "ItemIsBlueprint", "CurrencyId", "CurrencyIsBlueprint") + .IsUnique(); + + b.ToTable("VendingNotifications"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingStockNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MachineId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("PostedUtc") + .HasColumnType("TEXT"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("SoldOutSignature") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "MachineId") + .IsUnique(); + + b.ToTable("VendingStockNotifications"); + }); + + 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("RustPlusBot.Domain.Alarms.SmartAlarm", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanPlayerName", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanState", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Clans.ClanState", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Commands.ServerCommandSettings", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.Map.ServerMapSettings", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Map.ServerMapSettings", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingGridTrack", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingListingTrack", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingNotification", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingStockNotification", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .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/20260910143851_DropMirrorAndDeadTables.cs b/src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.cs new file mode 100644 index 00000000..7f6f64df --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.cs @@ -0,0 +1,171 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + /// + public partial class DropMirrorAndDeadTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Channels"); + + migrationBuilder.DropTable( + name: "EventSubscriptions"); + + migrationBuilder.DropTable( + name: "Guilds"); + + migrationBuilder.DropTable( + name: "Members"); + + migrationBuilder.DropTable( + name: "PairedEntities"); + + migrationBuilder.DropTable( + name: "Roles"); + + migrationBuilder.DropTable( + name: "Users"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Channels", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false), + GuildId = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + ParentId = table.Column(type: "INTEGER", nullable: true), + Type = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Channels", x => x.Id); + table.ForeignKey( + name: "FK_Channels_Channels_ParentId", + column: x => x.ParentId, + principalTable: "Channels", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "EventSubscriptions", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + EventKey = table.Column(type: "TEXT", maxLength: 64, nullable: false), + GuildId = table.Column(type: "INTEGER", nullable: false), + RustServerId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EventSubscriptions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Guilds", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + OwnerId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Guilds", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Members", + columns: table => new + { + GuildId = table.Column(type: "INTEGER", nullable: false), + UserId = table.Column(type: "INTEGER", nullable: false), + JoinedAt = table.Column(type: "TEXT", nullable: true), + Nickname = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Members", x => new { x.GuildId, x.UserId }); + }); + + migrationBuilder.CreateTable( + name: "PairedEntities", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + EntityId = table.Column(type: "INTEGER", nullable: false), + GuildId = table.Column(type: "INTEGER", nullable: false), + Kind = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), + RustServerId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PairedEntities", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Roles", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false), + Color = table.Column(type: "INTEGER", nullable: false), + GuildId = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Permissions = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false), + GlobalName = table.Column(type: "TEXT", nullable: true), + Username = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Channels_GuildId", + table: "Channels", + column: "GuildId"); + + migrationBuilder.CreateIndex( + name: "IX_Channels_ParentId", + table: "Channels", + column: "ParentId"); + + migrationBuilder.CreateIndex( + name: "IX_EventSubscriptions_GuildId_RustServerId", + table: "EventSubscriptions", + columns: new[] { "GuildId", "RustServerId" }); + + migrationBuilder.CreateIndex( + name: "IX_PairedEntities_GuildId_RustServerId", + table: "PairedEntities", + columns: new[] { "GuildId", "RustServerId" }); + + migrationBuilder.CreateIndex( + name: "IX_Roles_GuildId", + table: "Roles", + column: "GuildId"); + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/20260910145042_StampedTimestamps.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260910145042_StampedTimestamps.Designer.cs new file mode 100644 index 00000000..0b1a1b14 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260910145042_StampedTimestamps.Designer.cs @@ -0,0 +1,878 @@ +// +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("20260910145042_StampedTimestamps")] + partial class StampedTimestamps + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTriggeredUtc") + .HasColumnType("TEXT"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("PingEveryone") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("RelayToTeamChat") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartAlarms"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanPlayerName", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("SteamId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("ServerId", "SteamId"); + + b.ToTable("ClanPlayerNames"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanState", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("ClanId") + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Creator") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("InvitesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LogoHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxMemberCount") + .HasColumnType("INTEGER"); + + b.Property("MembersJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Motd") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("MotdAuthor") + .HasColumnType("INTEGER"); + + b.Property("MotdTimestamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RolesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.HasKey("ServerId"); + + b.ToTable("ClanStates"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.HasKey("ServerId"); + + b.ToTable("ServerCommandSettings"); + }); + + 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.Guilds.GuildSettings", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PingEveryoneOnWipe") + .HasColumnType("INTEGER"); + + b.HasKey("GuildId"); + + b.ToTable("GuildSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("GridStyle") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ShowGrid") + .HasColumnType("INTEGER"); + + b.Property("ShowMarkers") + .HasColumnType("INTEGER"); + + b.Property("ShowMonuments") + .HasColumnType("INTEGER"); + + b.Property("ShowPlayers") + .HasColumnType("INTEGER"); + + b.Property("ShowRigs") + .HasColumnType("INTEGER"); + + b.Property("ShowTunnels") + .HasColumnType("INTEGER"); + + b.Property("ShowVendor") + .HasColumnType("INTEGER"); + + b.HasKey("ServerId"); + + b.ToTable("ServerMapSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedByUserId") + .HasColumnType("INTEGER"); + + b.Property("FacepunchServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Ip") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastMapSeed") + .HasColumnType("INTEGER"); + + b.Property("LastMapSize") + .HasColumnType("INTEGER"); + + b.Property("LastWipeTimeUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("FacepunchServerId"); + + b.HasIndex("GuildId"); + + b.HasIndex("GuildId", "Ip", "Port") + .IsUnique(); + + b.ToTable("RustServers"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartStorageMonitors"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartSwitches"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingGridTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Grid") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RegisteredBySteamId") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "Grid") + .IsUnique(); + + b.ToTable("VendingGridTracks"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingListingTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CostPerOrder") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("ItemIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("RegisteredByUserId") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "ItemId", "ItemIsBlueprint", "CurrencyId", "CurrencyIsBlueprint") + .IsUnique(); + + b.ToTable("VendingListingTracks"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CurrencyId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("ItemIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("PostedUtc") + .HasColumnType("TEXT"); + + b.Property("ReferenceCostPerOrder") + .HasColumnType("INTEGER"); + + b.Property("ReferenceQuantity") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "ItemId", "ItemIsBlueprint", "CurrencyId", "CurrencyIsBlueprint") + .IsUnique(); + + b.ToTable("VendingNotifications"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingStockNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MachineId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("PostedUtc") + .HasColumnType("TEXT"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("SoldOutSignature") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "MachineId") + .IsUnique(); + + b.ToTable("VendingStockNotifications"); + }); + + 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("RustPlusBot.Domain.Alarms.SmartAlarm", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanPlayerName", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanState", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Clans.ClanState", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Commands.ServerCommandSettings", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.Map.ServerMapSettings", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Map.ServerMapSettings", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingGridTrack", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingListingTrack", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingNotification", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingStockNotification", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .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/20260910145042_StampedTimestamps.cs b/src/RustPlusBot.Persistence/Migrations/20260910145042_StampedTimestamps.cs new file mode 100644 index 00000000..bcd9affa --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260910145042_StampedTimestamps.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + /// + public partial class StampedTimestamps : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "CreatedUtc", + table: "VendingListingTracks", + newName: "CreatedAt"); + + migrationBuilder.RenameColumn( + name: "CreatedUtc", + table: "VendingGridTracks", + newName: "CreatedAt"); + + migrationBuilder.RenameColumn( + name: "CreatedUtc", + table: "SmartSwitches", + newName: "CreatedAt"); + + migrationBuilder.RenameColumn( + name: "CreatedUtc", + table: "SmartStorageMonitors", + newName: "CreatedAt"); + + migrationBuilder.RenameColumn( + name: "CreatedUtc", + table: "SmartAlarms", + newName: "CreatedAt"); + + migrationBuilder.RenameColumn( + name: "UpdatedUtc", + table: "ClanPlayerNames", + newName: "UpdatedAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "CreatedAt", + table: "VendingListingTracks", + newName: "CreatedUtc"); + + migrationBuilder.RenameColumn( + name: "CreatedAt", + table: "VendingGridTracks", + newName: "CreatedUtc"); + + migrationBuilder.RenameColumn( + name: "CreatedAt", + table: "SmartSwitches", + newName: "CreatedUtc"); + + migrationBuilder.RenameColumn( + name: "CreatedAt", + table: "SmartStorageMonitors", + newName: "CreatedUtc"); + + migrationBuilder.RenameColumn( + name: "CreatedAt", + table: "SmartAlarms", + newName: "CreatedUtc"); + + migrationBuilder.RenameColumn( + name: "UpdatedAt", + table: "ClanPlayerNames", + newName: "UpdatedUtc"); + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/20260910145600_GuildScopeIndexes.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260910145600_GuildScopeIndexes.Designer.cs new file mode 100644 index 00000000..221fb55f --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260910145600_GuildScopeIndexes.Designer.cs @@ -0,0 +1,888 @@ +// +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("20260910145600_GuildScopeIndexes")] + partial class GuildScopeIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTriggeredUtc") + .HasColumnType("TEXT"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("PingEveryone") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("RelayToTeamChat") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartAlarms"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanPlayerName", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("SteamId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("ServerId", "SteamId"); + + b.HasIndex("GuildId"); + + b.ToTable("ClanPlayerNames"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanState", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("ClanId") + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Creator") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("InvitesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LogoHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxMemberCount") + .HasColumnType("INTEGER"); + + b.Property("MembersJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Motd") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("MotdAuthor") + .HasColumnType("INTEGER"); + + b.Property("MotdTimestamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RolesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.HasKey("ServerId"); + + b.HasIndex("GuildId"); + + b.ToTable("ClanStates"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.HasKey("ServerId"); + + b.HasIndex("GuildId"); + + b.ToTable("ServerCommandSettings"); + }); + + 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.HasIndex("GuildId"); + + 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.Guilds.GuildSettings", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PingEveryoneOnWipe") + .HasColumnType("INTEGER"); + + b.HasKey("GuildId"); + + b.ToTable("GuildSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("GridStyle") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ShowGrid") + .HasColumnType("INTEGER"); + + b.Property("ShowMarkers") + .HasColumnType("INTEGER"); + + b.Property("ShowMonuments") + .HasColumnType("INTEGER"); + + b.Property("ShowPlayers") + .HasColumnType("INTEGER"); + + b.Property("ShowRigs") + .HasColumnType("INTEGER"); + + b.Property("ShowTunnels") + .HasColumnType("INTEGER"); + + b.Property("ShowVendor") + .HasColumnType("INTEGER"); + + b.HasKey("ServerId"); + + b.HasIndex("GuildId"); + + b.ToTable("ServerMapSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedByUserId") + .HasColumnType("INTEGER"); + + b.Property("FacepunchServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Ip") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastMapSeed") + .HasColumnType("INTEGER"); + + b.Property("LastMapSize") + .HasColumnType("INTEGER"); + + b.Property("LastWipeTimeUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("FacepunchServerId"); + + b.HasIndex("GuildId"); + + b.HasIndex("GuildId", "Ip", "Port") + .IsUnique(); + + b.ToTable("RustServers"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartStorageMonitors"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartSwitches"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingGridTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Grid") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RegisteredBySteamId") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "Grid") + .IsUnique(); + + b.ToTable("VendingGridTracks"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingListingTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CostPerOrder") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("ItemIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("RegisteredByUserId") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "ItemId", "ItemIsBlueprint", "CurrencyId", "CurrencyIsBlueprint") + .IsUnique(); + + b.ToTable("VendingListingTracks"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CurrencyId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("ItemIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("PostedUtc") + .HasColumnType("TEXT"); + + b.Property("ReferenceCostPerOrder") + .HasColumnType("INTEGER"); + + b.Property("ReferenceQuantity") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "ItemId", "ItemIsBlueprint", "CurrencyId", "CurrencyIsBlueprint") + .IsUnique(); + + b.ToTable("VendingNotifications"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingStockNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MachineId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("PostedUtc") + .HasColumnType("TEXT"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("SoldOutSignature") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "MachineId") + .IsUnique(); + + b.ToTable("VendingStockNotifications"); + }); + + 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("RustPlusBot.Domain.Alarms.SmartAlarm", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanPlayerName", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanState", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Clans.ClanState", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Commands.ServerCommandSettings", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.Map.ServerMapSettings", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Map.ServerMapSettings", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingGridTrack", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingListingTrack", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingNotification", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingStockNotification", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .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/20260910145600_GuildScopeIndexes.cs b/src/RustPlusBot.Persistence/Migrations/20260910145600_GuildScopeIndexes.cs new file mode 100644 index 00000000..1f822930 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260910145600_GuildScopeIndexes.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + /// + public partial class GuildScopeIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_ServerMapSettings_GuildId", + table: "ServerMapSettings", + column: "GuildId"); + + migrationBuilder.CreateIndex( + name: "IX_ServerCommandSettings_GuildId", + table: "ServerCommandSettings", + column: "GuildId"); + + migrationBuilder.CreateIndex( + name: "IX_ConnectionStates_GuildId", + table: "ConnectionStates", + column: "GuildId"); + + migrationBuilder.CreateIndex( + name: "IX_ClanStates_GuildId", + table: "ClanStates", + column: "GuildId"); + + migrationBuilder.CreateIndex( + name: "IX_ClanPlayerNames_GuildId", + table: "ClanPlayerNames", + column: "GuildId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ServerMapSettings_GuildId", + table: "ServerMapSettings"); + + migrationBuilder.DropIndex( + name: "IX_ServerCommandSettings_GuildId", + table: "ServerCommandSettings"); + + migrationBuilder.DropIndex( + name: "IX_ConnectionStates_GuildId", + table: "ConnectionStates"); + + migrationBuilder.DropIndex( + name: "IX_ClanStates_GuildId", + table: "ClanStates"); + + migrationBuilder.DropIndex( + name: "IX_ClanPlayerNames_GuildId", + table: "ClanPlayerNames"); + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/20260910150057_ChatWebhooks.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260910150057_ChatWebhooks.Designer.cs new file mode 100644 index 00000000..f13ebda4 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260910150057_ChatWebhooks.Designer.cs @@ -0,0 +1,931 @@ +// +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("20260910150057_ChatWebhooks")] + partial class ChatWebhooks + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("Persistord.Managed.Entities.ManagedWebhook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelDiscordId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Token") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "Scope", "Key") + .IsUnique(); + + b.ToTable("ManagedWebhooks", (string)null); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTriggeredUtc") + .HasColumnType("TEXT"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("PingEveryone") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("RelayToTeamChat") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartAlarms"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanPlayerName", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("SteamId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("ServerId", "SteamId"); + + b.HasIndex("GuildId"); + + b.ToTable("ClanPlayerNames"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanState", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("ClanId") + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Creator") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("InvitesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LogoHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxMemberCount") + .HasColumnType("INTEGER"); + + b.Property("MembersJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Motd") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("MotdAuthor") + .HasColumnType("INTEGER"); + + b.Property("MotdTimestamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RolesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.HasKey("ServerId"); + + b.HasIndex("GuildId"); + + b.ToTable("ClanStates"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.HasKey("ServerId"); + + b.HasIndex("GuildId"); + + b.ToTable("ServerCommandSettings"); + }); + + 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.HasIndex("GuildId"); + + 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.Guilds.GuildSettings", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PingEveryoneOnWipe") + .HasColumnType("INTEGER"); + + b.HasKey("GuildId"); + + b.ToTable("GuildSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("GridStyle") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ShowGrid") + .HasColumnType("INTEGER"); + + b.Property("ShowMarkers") + .HasColumnType("INTEGER"); + + b.Property("ShowMonuments") + .HasColumnType("INTEGER"); + + b.Property("ShowPlayers") + .HasColumnType("INTEGER"); + + b.Property("ShowRigs") + .HasColumnType("INTEGER"); + + b.Property("ShowTunnels") + .HasColumnType("INTEGER"); + + b.Property("ShowVendor") + .HasColumnType("INTEGER"); + + b.HasKey("ServerId"); + + b.HasIndex("GuildId"); + + b.ToTable("ServerMapSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedByUserId") + .HasColumnType("INTEGER"); + + b.Property("FacepunchServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Ip") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastMapSeed") + .HasColumnType("INTEGER"); + + b.Property("LastMapSize") + .HasColumnType("INTEGER"); + + b.Property("LastWipeTimeUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("FacepunchServerId"); + + b.HasIndex("GuildId"); + + b.HasIndex("GuildId", "Ip", "Port") + .IsUnique(); + + b.ToTable("RustServers"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartStorageMonitors"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartSwitches"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingGridTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Grid") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RegisteredBySteamId") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "Grid") + .IsUnique(); + + b.ToTable("VendingGridTracks"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingListingTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CostPerOrder") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("ItemIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("RegisteredByUserId") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "ItemId", "ItemIsBlueprint", "CurrencyId", "CurrencyIsBlueprint") + .IsUnique(); + + b.ToTable("VendingListingTracks"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CurrencyId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("ItemIsBlueprint") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("PostedUtc") + .HasColumnType("TEXT"); + + b.Property("ReferenceCostPerOrder") + .HasColumnType("INTEGER"); + + b.Property("ReferenceQuantity") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "ItemId", "ItemIsBlueprint", "CurrencyId", "CurrencyIsBlueprint") + .IsUnique(); + + b.ToTable("VendingNotifications"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingStockNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MachineId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("PostedUtc") + .HasColumnType("TEXT"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("SoldOutSignature") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "MachineId") + .IsUnique(); + + b.ToTable("VendingStockNotifications"); + }); + + 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("RustPlusBot.Domain.Alarms.SmartAlarm", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanPlayerName", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanState", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Clans.ClanState", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Commands.ServerCommandSettings", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.Map.ServerMapSettings", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Map.ServerMapSettings", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingGridTrack", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingListingTrack", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingNotification", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingStockNotification", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .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/20260910150057_ChatWebhooks.cs b/src/RustPlusBot.Persistence/Migrations/20260910150057_ChatWebhooks.cs new file mode 100644 index 00000000..b573c266 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260910150057_ChatWebhooks.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + /// + public partial class ChatWebhooks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ManagedWebhooks", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ChannelDiscordId = table.Column(type: "INTEGER", nullable: false), + Token = table.Column(type: "TEXT", nullable: false), + Scope = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Key = table.Column(type: "TEXT", maxLength: 64, nullable: false), + DiscordId = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + GuildId = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ManagedWebhooks", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ManagedWebhooks_GuildId_Scope_Key", + table: "ManagedWebhooks", + columns: new[] { "GuildId", "Scope", "Key" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ManagedWebhooks"); + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index 3d320fd5..84250ffd 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -17,109 +17,47 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); - modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b => + modelBuilder.Entity("Persistord.Managed.Entities.ManagedWebhook", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("GuildId") - .HasColumnType("INTEGER"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("ParentId") - .HasColumnType("INTEGER"); - - b.Property("Type") + b.Property("ChannelDiscordId") .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() + b.Property("CreatedAt") .HasColumnType("TEXT"); - b.Property("OwnerId") + b.Property("DiscordId") .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") + b.Property("Key") + .IsRequired() + .HasMaxLength(64) .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") + b.Property("Scope") .IsRequired() + .HasMaxLength(64) .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") + b.Property("Token") + .IsRequired() .HasColumnType("TEXT"); - b.Property("Username") - .IsRequired() + b.Property("UpdatedAt") .HasColumnType("TEXT"); b.HasKey("Id"); - b.ToTable("Users"); + b.HasIndex("GuildId", "Scope", "Key") + .IsUnique(); + + b.ToTable("ManagedWebhooks", (string)null); }); modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b => @@ -128,7 +66,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("CreatedUtc") + b.Property("CreatedAt") .HasColumnType("TEXT"); b.Property("EntityId") @@ -194,11 +132,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(64) .HasColumnType("TEXT"); - b.Property("UpdatedUtc") + b.Property("UpdatedAt") .HasColumnType("TEXT"); b.HasKey("ServerId", "SteamId"); + b.HasIndex("GuildId"); + b.ToTable("ClanPlayerNames"); }); @@ -264,6 +204,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("ServerId"); + b.HasIndex("GuildId"); + b.ToTable("ClanStates"); }); @@ -285,6 +227,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("ServerId"); + b.HasIndex("GuildId"); + b.ToTable("ServerCommandSettings"); }); @@ -310,6 +254,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("RustServerId"); + b.HasIndex("GuildId"); + b.ToTable("ConnectionStates"); }); @@ -378,60 +324,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -484,6 +376,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("ServerId"); + b.HasIndex("GuildId"); + b.ToTable("ServerMapSettings"); }); @@ -542,7 +436,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("CreatedUtc") + b.Property("CreatedAt") .HasColumnType("TEXT"); b.Property("EntityId") @@ -586,7 +480,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("CreatedUtc") + b.Property("CreatedAt") .HasColumnType("TEXT"); b.Property("EntityId") @@ -633,7 +527,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("CreatedUtc") + b.Property("CreatedAt") .HasColumnType("TEXT"); b.Property("Grid") @@ -669,7 +563,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CostPerOrder") .HasColumnType("INTEGER"); - b.Property("CreatedUtc") + b.Property("CreatedAt") .HasColumnType("TEXT"); b.Property("CurrencyId") @@ -888,14 +782,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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.Alarms.SmartAlarm", b => { b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs index 026a41a6..6f565858 100644 --- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs @@ -1,7 +1,10 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Persistord.Core.Interception; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Persistence.Alarms; +using RustPlusBot.Persistence.Chat; using RustPlusBot.Persistence.Clans; using RustPlusBot.Persistence.Commands; using RustPlusBot.Persistence.Connections; @@ -28,7 +31,15 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi { ArgumentNullException.ThrowIfNull(services); - services.AddDbContextFactory(options => options.UseSqlite(connectionString)); + // The clock the TimestampInterceptor stamps from. TryAdd so a host (or a test) that registered + // its own TimeProvider keeps it. + services.TryAddSingleton(TimeProvider.System); + + // Every ICreatedAt/IUpdatedAt row is stamped by the interceptor rather than by each store, so + // a store that forgets to touch a timestamp can no longer write a stale one. + services.AddDbContextFactory((sp, options) => options + .UseSqlite(connectionString) + .AddInterceptors(new TimestampInterceptor(sp.GetRequiredService()))); // AddDbContextFactory registers only the singleton factory, not a scoped context. // Register a scoped BotDbContext sourced from the factory so the scoped services below @@ -51,6 +62,7 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj b/src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj index 868ce442..65a39354 100644 --- a/src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj +++ b/src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj @@ -20,6 +20,7 @@ from EF Core Sqlite (CVE-2025-6965). Flows to all DB-touching projects via this one. --> + diff --git a/src/RustPlusBot.Persistence/StorageMonitors/StorageMonitorStore.cs b/src/RustPlusBot.Persistence/StorageMonitors/StorageMonitorStore.cs index 1946583f..9b9e8846 100644 --- a/src/RustPlusBot.Persistence/StorageMonitors/StorageMonitorStore.cs +++ b/src/RustPlusBot.Persistence/StorageMonitors/StorageMonitorStore.cs @@ -1,4 +1,3 @@ -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.StorageMonitors; using RustPlusBot.Persistence.Devices; @@ -6,6 +5,5 @@ namespace RustPlusBot.Persistence.StorageMonitors; /// EF-backed ; every member comes from the shared device store. /// The bot database context. -/// Supplies the creation timestamp. -public sealed class StorageMonitorStore(BotDbContext context, IClock clock) - : PairedDeviceStore(context, clock), IStorageMonitorStore; +public sealed class StorageMonitorStore(BotDbContext context) + : PairedDeviceStore(context), IStorageMonitorStore; diff --git a/src/RustPlusBot.Persistence/Switches/SwitchStore.cs b/src/RustPlusBot.Persistence/Switches/SwitchStore.cs index e633c3fe..59bf4061 100644 --- a/src/RustPlusBot.Persistence/Switches/SwitchStore.cs +++ b/src/RustPlusBot.Persistence/Switches/SwitchStore.cs @@ -1,4 +1,3 @@ -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Switches; using RustPlusBot.Persistence.Devices; @@ -6,9 +5,8 @@ namespace RustPlusBot.Persistence.Switches; /// EF-backed . /// The bot database context. -/// Supplies the creation timestamp. -public sealed class SwitchStore(BotDbContext context, IClock clock) - : PairedDeviceStore(context, clock), ISwitchStore +public sealed class SwitchStore(BotDbContext context) + : PairedDeviceStore(context), ISwitchStore { /// public Task UpdateStateAsync( diff --git a/src/RustPlusBot.Persistence/Vending/VendingStore.cs b/src/RustPlusBot.Persistence/Vending/VendingStore.cs index 173e48f5..fad4b056 100644 --- a/src/RustPlusBot.Persistence/Vending/VendingStore.cs +++ b/src/RustPlusBot.Persistence/Vending/VendingStore.cs @@ -1,5 +1,5 @@ using Microsoft.EntityFrameworkCore; -using RustPlusBot.Abstractions.Time; +using Persistord.Core; using RustPlusBot.Abstractions.Vending; using RustPlusBot.Domain.Vending; @@ -7,8 +7,11 @@ namespace RustPlusBot.Persistence.Vending; /// EF-backed . /// The bot database context. -/// Supplies write timestamps. -internal sealed class VendingStore(BotDbContext context, IClock clock) : IVendingStore +/// +/// Supplies the post timestamps, which only move when a message is (re)posted; the same clock the +/// TimestampInterceptor stamps CreatedAt from. +/// +internal sealed class VendingStore(BotDbContext context, TimeProvider timeProvider) : IVendingStore { /// public async Task> ListGridsAsync( @@ -31,47 +34,19 @@ public async Task AddGridAsync(ulong guildId, CancellationToken ct = default) { var normalized = Normalize(grid); - var existing = await context.VendingGridTracks - .FirstOrDefaultAsync(g => g.GuildId == guildId && g.ServerId == serverId && g.Grid == normalized, ct) - .ConfigureAwait(false); - if (existing is not null) - { - return; // Re-registering a cell is a no-op, not an error: !vtrack is a natural thing to repeat. - } - - var track = new VendingGridTrack - { - GuildId = guildId, - ServerId = serverId, - Grid = normalized, - RegisteredBySteamId = steamId, - CreatedUtc = clock.UtcNow, - }; - context.VendingGridTracks.Add(track); - - try - { - await context.SaveChangesAsync(ct).ConfigureAwait(false); - } - catch (DbUpdateException) - { - // Two callers can both see "not present" above and both insert; the unique index on - // (GuildId, ServerId, Grid) then rejects whichever save lands second. That is a race on an - // operation this store's own contract calls idempotent, not a real failure, so re-query - // rather than surface it: if the row exists now, the desired end state was reached (by the - // other caller) and we return normally; if it still doesn't, this was a different failure - // and must propagate. The failed insert has to come off the tracker first, or it re-attempts - // on the very next SaveChanges this context makes. - context.Entry(track).State = EntityState.Detached; - var winner = await context.VendingGridTracks - .FirstOrDefaultAsync(g => g.GuildId == guildId && g.ServerId == serverId && g.Grid == normalized, ct) - .ConfigureAwait(false); - if (winner is null) - { - throw; - } - } + // Re-registering a cell is a no-op, not an error: !vtrack is a natural thing to repeat, and two + // callers can race on the same cell. The empty mutation keeps the row the first registration + // wrote — including who registered it — and Persistord recovers the unique-index race for us. + await context.VendingGridTracks.UpsertAsync( + g => g.GuildId == guildId && g.ServerId == serverId && g.Grid == normalized, + () => new VendingGridTrack + { + GuildId = guildId, ServerId = serverId, Grid = normalized, RegisteredBySteamId = steamId, + }, + _ => { }, + ct) + .ConfigureAwait(false); } /// @@ -121,34 +96,28 @@ public async Task UpsertListingAsync( ulong userId, CancellationToken ct = default) { - var row = await context.VendingListingTracks - .FirstOrDefaultAsync( + await context.VendingListingTracks.UpsertAsync( l => l.GuildId == guildId && l.ServerId == serverId && l.ItemId == key.ItemId && l.ItemIsBlueprint == key.ItemIsBlueprint && l.CurrencyId == key.CurrencyId && l.CurrencyIsBlueprint == key.CurrencyIsBlueprint, + () => new VendingListingTrack + { + GuildId = guildId, + ServerId = serverId, + ItemId = key.ItemId, + ItemIsBlueprint = key.ItemIsBlueprint, + CurrencyId = key.CurrencyId, + CurrencyIsBlueprint = key.CurrencyIsBlueprint, + RegisteredByUserId = userId, + }, + row => + { + row.Quantity = Math.Max(1, quantity); + row.CostPerOrder = costPerOrder; + }, ct) .ConfigureAwait(false); - - if (row is null) - { - row = new VendingListingTrack - { - GuildId = guildId, - ServerId = serverId, - ItemId = key.ItemId, - ItemIsBlueprint = key.ItemIsBlueprint, - CurrencyId = key.CurrencyId, - CurrencyIsBlueprint = key.CurrencyIsBlueprint, - RegisteredByUserId = userId, - CreatedUtc = clock.UtcNow, - }; - context.VendingListingTracks.Add(row); - } - - row.Quantity = Math.Max(1, quantity); - row.CostPerOrder = costPerOrder; - await context.SaveChangesAsync(ct).ConfigureAwait(false); } /// @@ -219,7 +188,7 @@ public async Task UpsertNotificationAsync( MessageId = messageId, ReferenceQuantity = referenceQuantity, ReferenceCostPerOrder = referenceCostPerOrder, - PostedUtc = clock.UtcNow, + PostedUtc = timeProvider.GetUtcNow(), }; context.VendingNotifications.Add(row); await context.SaveChangesAsync(ct).ConfigureAwait(false); @@ -238,7 +207,7 @@ public async Task UpsertNotificationAsync( // place — and rewriting the timestamp then would make the column read "5 seconds ago" forever. if (row.MessageId != messageId) { - row.PostedUtc = clock.UtcNow; + row.PostedUtc = timeProvider.GetUtcNow(); } row.MessageId = messageId; @@ -308,7 +277,7 @@ public async Task UpsertStockNotificationAsync( MachineId = machineId, MessageId = messageId, SoldOutSignature = soldOutSignature, - PostedUtc = clock.UtcNow, + PostedUtc = timeProvider.GetUtcNow(), }; context.VendingStockNotifications.Add(row); await context.SaveChangesAsync(ct).ConfigureAwait(false); @@ -323,7 +292,7 @@ public async Task UpsertStockNotificationAsync( if (row.MessageId != messageId) { - row.PostedUtc = clock.UtcNow; + row.PostedUtc = timeProvider.GetUtcNow(); } row.MessageId = messageId; diff --git a/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs b/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs index ac77c40d..341e3eb6 100644 --- a/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs +++ b/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs @@ -1,5 +1,5 @@ using Microsoft.EntityFrameworkCore; -using RustPlusBot.Abstractions.Time; +using Persistord.Core; using RustPlusBot.Domain.Guilds; using RustPlusBot.Domain.Workspace; @@ -7,8 +7,7 @@ namespace RustPlusBot.Persistence.Workspace; /// EF Core implementation of over . /// The bot database context. -/// The clock used for create/update timestamps. -public sealed class WorkspaceStore(BotDbContext context, IClock clock) : IWorkspaceStore +public sealed class WorkspaceStore(BotDbContext context) : IWorkspaceStore { /// public Task GetCategoryAsync(ulong guildId, @@ -21,22 +20,12 @@ public sealed class WorkspaceStore(BotDbContext context, IClock clock) : IWorksp public async Task SaveCategoryAsync(ProvisionedCategory category, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(category); - var existing = await context.ProvisionedCategories - .SingleOrDefaultAsync(c => c.GuildId == category.GuildId && c.RustServerId == category.RustServerId, + await context.ProvisionedCategories.UpsertAsync( + c => c.GuildId == category.GuildId && c.RustServerId == category.RustServerId, + () => category, + row => row.DiscordCategoryId = category.DiscordCategoryId, cancellationToken) .ConfigureAwait(false); - - if (existing is null) - { - category.CreatedAt = clock.UtcNow; - context.ProvisionedCategories.Add(category); - } - else - { - existing.DiscordCategoryId = category.DiscordCategoryId; - } - - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } /// @@ -52,24 +41,13 @@ await context.ProvisionedChannels public async Task SaveChannelAsync(ProvisionedChannel channel, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(channel); - var existing = await context.ProvisionedChannels - .SingleOrDefaultAsync( + await context.ProvisionedChannels.UpsertAsync( c => c.GuildId == channel.GuildId && c.RustServerId == channel.RustServerId && c.ChannelKey == channel.ChannelKey, + () => channel, + row => row.DiscordChannelId = channel.DiscordChannelId, cancellationToken) .ConfigureAwait(false); - - if (existing is null) - { - channel.CreatedAt = clock.UtcNow; - context.ProvisionedChannels.Add(channel); - } - else - { - existing.DiscordChannelId = channel.DiscordChannelId; - } - - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } /// @@ -85,27 +63,17 @@ public async Task SaveChannelAsync(ProvisionedChannel channel, CancellationToken public async Task SaveMessageAsync(ProvisionedMessage message, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(message); - var existing = await context.ProvisionedMessages - .SingleOrDefaultAsync( + await context.ProvisionedMessages.UpsertAsync( m => m.GuildId == message.GuildId && m.RustServerId == message.RustServerId && m.MessageKey == message.MessageKey, + () => message, + row => + { + row.DiscordChannelId = message.DiscordChannelId; + row.DiscordMessageId = message.DiscordMessageId; + }, cancellationToken) .ConfigureAwait(false); - - if (existing is null) - { - message.CreatedAt = clock.UtcNow; - message.UpdatedAt = clock.UtcNow; - context.ProvisionedMessages.Add(message); - } - else - { - existing.DiscordChannelId = message.DiscordChannelId; - existing.DiscordMessageId = message.DiscordMessageId; - existing.UpdatedAt = clock.UtcNow; - } - - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } /// @@ -118,27 +86,17 @@ public async Task GetCultureAsync(ulong guildId, CancellationToken cance } /// - public async Task SetCultureAsync(ulong guildId, string culture, CancellationToken cancellationToken = default) - { - var settings = await context.GuildSettings - .SingleOrDefaultAsync(s => s.GuildId == guildId, cancellationToken) + public async Task SetCultureAsync(ulong guildId, string culture, CancellationToken cancellationToken = default) => + await context.GuildSettings.UpsertAsync( + s => s.GuildId == guildId, + () => new GuildSettings + { + GuildId = guildId + }, + row => row.Culture = culture, + cancellationToken) .ConfigureAwait(false); - if (settings is null) - { - context.GuildSettings.Add(new GuildSettings - { - GuildId = guildId, Culture = culture - }); - } - else - { - settings.Culture = culture; - } - - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } - /// public async Task GetPingEveryoneOnWipeAsync(ulong guildId, CancellationToken cancellationToken = default) { @@ -151,27 +109,17 @@ public async Task GetPingEveryoneOnWipeAsync(ulong guildId, CancellationTo /// public async Task SetPingEveryoneOnWipeAsync(ulong guildId, bool enabled, - CancellationToken cancellationToken = default) - { - var settings = await context.GuildSettings - .SingleOrDefaultAsync(s => s.GuildId == guildId, cancellationToken) + CancellationToken cancellationToken = default) => + await context.GuildSettings.UpsertAsync( + s => s.GuildId == guildId, + () => new GuildSettings + { + GuildId = guildId + }, + row => row.PingEveryoneOnWipe = enabled, + cancellationToken) .ConfigureAwait(false); - if (settings is null) - { - context.GuildSettings.Add(new GuildSettings - { - GuildId = guildId, PingEveryoneOnWipe = enabled - }); - } - else - { - settings.PingEveryoneOnWipe = enabled; - } - - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } - /// public async Task DeleteChannelAsync(ulong guildId, Guid? serverId, diff --git a/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs index b65179ba..64fbb631 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs @@ -50,9 +50,9 @@ await relay.RelayAsync( CancellationToken.None); await poster.Received(1) - .PostAsync(ChatChannelKind.Team, TeamChannel, "Bob", "hi team", Arg.Any()); + .PostAsync(ChatChannelKind.Team, guild, TeamChannel, "Bob", "hi team", Arg.Any()); await poster.Received(1) - .PostAsync(ChatChannelKind.Clan, ClanChannel, "Bob", "hi clan", Arg.Any()); + .PostAsync(ChatChannelKind.Clan, guild, ClanChannel, "Bob", "hi clan", Arg.Any()); } [Fact] @@ -83,7 +83,8 @@ await relay.RelayAsync( FromActivePlayer: true), CancellationToken.None); - await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); } diff --git a/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs b/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs index 1496445a..8d1457af 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs @@ -59,7 +59,7 @@ public async Task Posts_a_normal_message_via_webhook(ChatChannelKind kind) await relay.RelayAsync(line, CancellationToken.None); - await poster.Received(1).PostAsync(kind, ChannelFor(kind), "Bob", "hello", Arg.Any()); + await poster.Received(1).PostAsync(kind, 10UL, ChannelFor(kind), "Bob", "hello", Arg.Any()); } [Theory] @@ -73,7 +73,8 @@ public async Task Drops_a_bot_prefixed_line_from_the_active_player(ChatChannelKi await relay.RelayAsync(line, CancellationToken.None); - await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); } @@ -87,7 +88,8 @@ public async Task Keeps_a_bot_prefixed_line_from_another_player(ChatChannelKind await relay.RelayAsync(line, CancellationToken.None); - await poster.Received(1).PostAsync(kind, ChannelFor(kind), "Bob", "[R+] hi", Arg.Any()); + await poster.Received(1) + .PostAsync(kind, 10UL, ChannelFor(kind), "Bob", "[R+] hi", Arg.Any()); } [Theory] @@ -101,7 +103,8 @@ public async Task Drops_our_own_echo(ChatChannelKind kind) await relay.RelayAsync(echo, CancellationToken.None); - await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); } @@ -116,7 +119,7 @@ public async Task Posts_an_active_player_line_that_is_not_an_echo(ChatChannelKin await relay.RelayAsync(line, CancellationToken.None); await poster.Received(1) - .PostAsync(kind, ChannelFor(kind), "BotPlayer", "genuine", Arg.Any()); + .PostAsync(kind, 10UL, ChannelFor(kind), "BotPlayer", "genuine", Arg.Any()); } [Theory] @@ -131,7 +134,8 @@ public async Task Drops_a_command_invocation(ChatChannelKind kind, bool fromActi await relay.RelayAsync(line, CancellationToken.None); - await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); } @@ -145,7 +149,8 @@ public async Task Ignores_leading_whitespace_when_matching_the_command_prefix(Ch await relay.RelayAsync(line, CancellationToken.None); - await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); } @@ -162,8 +167,9 @@ await relay.RelayAsync(new RelayedChatLine(kind, 10UL, Guid.Empty, "Bob", "!not CancellationToken.None); await poster.Received(1) - .PostAsync(kind, ChannelFor(kind), "Bob", "!not a command", Arg.Any()); - await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + .PostAsync(kind, 10UL, ChannelFor(kind), "Bob", "!not a command", Arg.Any()); + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), ".pop", Arg.Any()); } @@ -179,7 +185,8 @@ public async Task Does_nothing_when_the_channel_is_not_provisioned(ChatChannelKi await relay.RelayAsync(line, CancellationToken.None); - await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); } @@ -193,7 +200,7 @@ public async Task A_team_dedup_entry_does_not_suppress_a_clan_line() await relay.RelayAsync(line, CancellationToken.None); - await poster.Received(1).PostAsync(ChatChannelKind.Clan, ClanChannel, "BotPlayer", "[Alice] hello", + await poster.Received(1).PostAsync(ChatChannelKind.Clan, 10UL, ClanChannel, "BotPlayer", "[Alice] hello", Arg.Any()); } @@ -207,7 +214,7 @@ public async Task A_clan_dedup_entry_does_not_suppress_a_team_line() await relay.RelayAsync(line, CancellationToken.None); - await poster.Received(1).PostAsync(ChatChannelKind.Team, TeamChannel, "BotPlayer", "[Alice] hello", + await poster.Received(1).PostAsync(ChatChannelKind.Team, 10UL, TeamChannel, "BotPlayer", "[Alice] hello", Arg.Any()); } @@ -221,8 +228,9 @@ public async Task Posts_a_clan_line_to_the_clan_channel_not_the_team_channel() await relay.RelayAsync(line, CancellationToken.None); await poster.Received(1) - .PostAsync(ChatChannelKind.Clan, ClanChannel, "Bob", "hello", Arg.Any()); - await poster.DidNotReceive().PostAsync(Arg.Any(), TeamChannel, Arg.Any(), + .PostAsync(ChatChannelKind.Clan, 10UL, ClanChannel, "Bob", "hello", Arg.Any()); + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), TeamChannel, + Arg.Any(), Arg.Any(), Arg.Any()); await team.DidNotReceive().GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()); } @@ -237,7 +245,7 @@ public async Task Posts_a_team_line_to_the_team_channel_not_the_clan_channel() await relay.RelayAsync(line, CancellationToken.None); await poster.Received(1) - .PostAsync(ChatChannelKind.Team, TeamChannel, "Bob", "hello", Arg.Any()); + .PostAsync(ChatChannelKind.Team, 10UL, TeamChannel, "Bob", "hello", Arg.Any()); await clan.DidNotReceive().GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()); } } diff --git a/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs b/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs index 81856349..70dca6bf 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs @@ -101,8 +101,10 @@ await bus.PublishAsync( } await poster.Received() - .PostAsync(ChatChannelKind.Team, TeamChannel, "dave", "hi team", Arg.Any()); - await poster.DidNotReceive().PostAsync(ChatChannelKind.Clan, Arg.Any(), Arg.Any(), + .PostAsync(ChatChannelKind.Team, Arg.Any(), TeamChannel, "dave", "hi team", + Arg.Any()); + await poster.DidNotReceive().PostAsync(ChatChannelKind.Clan, Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); await service.StopAsync(default); @@ -124,8 +126,10 @@ await bus.PublishAsync( } await poster.Received() - .PostAsync(ChatChannelKind.Clan, ClanChannel, "dave", "hi clan", Arg.Any()); - await poster.DidNotReceive().PostAsync(ChatChannelKind.Team, Arg.Any(), Arg.Any(), + .PostAsync(ChatChannelKind.Clan, Arg.Any(), ClanChannel, "dave", "hi clan", + Arg.Any()); + await poster.DidNotReceive().PostAsync(ChatChannelKind.Team, Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); // The clan API reports members by Steam id only, so chat is the only place names are learned. @@ -150,7 +154,8 @@ public async Task StartAsync_then_StopAsync_completes_cleanly() public async Task RelayLoop_faults_on_poster_exception_but_StopAsync_completes_cleanly() { var (service, bus, poster, _) = Build(); - poster.PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + poster.PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("simulated fault")); @@ -167,7 +172,7 @@ await bus.PublishAsync( // The relay threw, causing the loop to fault and complete (LogTeamRelayLoopFaulted). StopAsync joins the // faulted task cleanly — no rethrow. This is crash-isolation, not per-event resilience. - await poster.Received().PostAsync(ChatChannelKind.Team, Arg.Any(), "Bob", "boom", + await poster.Received().PostAsync(ChatChannelKind.Team, Arg.Any(), Arg.Any(), "Bob", "boom", Arg.Any()); await service.StopAsync(default); } @@ -194,7 +199,8 @@ await bus.PublishAsync( await service.StopAsync(default); await poster.Received() - .PostAsync(ChatChannelKind.Clan, ClanChannel, "dave", "hi clan", Arg.Any()); + .PostAsync(ChatChannelKind.Clan, Arg.Any(), ClanChannel, "dave", "hi clan", + Arg.Any()); } [Fact] @@ -204,7 +210,7 @@ public async Task A_failing_clan_relay_costs_its_own_line_and_not_the_subscripti // would end the clan subscription and #clan-chat would stay silent until the bot restarted. var (service, bus, poster, _) = Build(); var attempts = 0; - poster.PostAsync(ChatChannelKind.Clan, Arg.Any(), Arg.Any(), Arg.Any(), + poster.PostAsync(ChatChannelKind.Clan, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(_ => Interlocked.Increment(ref attempts) == 1 ? throw new TimeoutException("Discord did not answer.") diff --git a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs index 601c1ec1..cabac8e6 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs @@ -1,8 +1,8 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; @@ -41,15 +41,14 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMem services.AddSingleton(dm); services.AddSingleton(bus); - var cs = $"DataSource=alarmpriming-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs index 6bcb2e3a..a80e903a 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs @@ -1,9 +1,9 @@ using System.Collections.Concurrent; -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; @@ -42,15 +42,14 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMem services.AddSingleton(dm); services.AddSingleton(bus); - var cs = $"DataSource=alarmsweep-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs index 925ab2f1..e2884446 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs @@ -1,8 +1,8 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; @@ -46,16 +46,11 @@ private static Harness CreateHarness(FakeRustSocketSource source) // Each scope opens its OWN connection to a shared-cache in-memory database, so the background // supervisor loop and the test's polling never run concurrent commands on a single SqliteConnection // (which throws "active statements" misuse errors). One kept-open connection keeps the in-memory DB alive. - var connectionString = $"DataSource=clansup-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(connectionString); - keepAlive.Open(); - using (var seed = new BotDbContext( - new DbContextOptionsBuilder().UseSqlite(connectionString).Options)) - { - seed.Database.Migrate(); - } + var database = SqliteTestDatabase.Shared(); + var connectionString = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddScoped(_ => new BotDbContext( new DbContextOptionsBuilder().UseSqlite(connectionString).Options)); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs index 88e1ab0b..fd507888 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs @@ -1,11 +1,11 @@ using System.Diagnostics; using System.Security.Cryptography; -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; @@ -52,16 +52,11 @@ private static Harness CreateHarness( // Each scope opens its OWN connection to a shared-cache in-memory database, so the background // supervisor loop and the test's polling never run concurrent commands on a single SqliteConnection // (which throws "active statements" misuse errors). One kept-open connection keeps the in-memory DB alive. - var connectionString = $"DataSource=connsup-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(connectionString); - keepAlive.Open(); - using (var seed = new BotDbContext( - new DbContextOptionsBuilder().UseSqlite(connectionString).Options)) - { - seed.Database.Migrate(); - } + var database = SqliteTestDatabase.Shared(); + var connectionString = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddScoped(_ => new BotDbContext( new DbContextOptionsBuilder().UseSqlite(connectionString).Options)); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs index 22c70b45..a5427a41 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs @@ -1,8 +1,8 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; @@ -38,15 +38,14 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor) Creat services.AddSingleton(dm); services.AddSingleton(); - var cs = $"DataSource=mapimage-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); services.AddScoped(); 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 f3eb42a5..ec782296 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj +++ b/tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs index 001caef1..9f08edf4 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs @@ -1,8 +1,8 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; @@ -38,15 +38,14 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor) Creat services.AddSingleton(dm); services.AddSingleton(); - var cs = $"DataSource=serverquery-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorPrimingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorPrimingTests.cs index 41a608d3..96484b87 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorPrimingTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorPrimingTests.cs @@ -1,8 +1,8 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; @@ -41,15 +41,14 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMem services.AddSingleton(dm); services.AddSingleton(bus); - var cs = $"DataSource=storagepriming-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs b/tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs index f1385e79..6771322e 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs @@ -1,8 +1,8 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; @@ -41,15 +41,14 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMem services.AddSingleton(dm); services.AddSingleton(bus); - var cs = $"DataSource=storagesweep-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Connections.Tests/SwitchPrimingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/SwitchPrimingTests.cs index 68c78837..e066e6d1 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/SwitchPrimingTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/SwitchPrimingTests.cs @@ -1,8 +1,8 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; @@ -41,15 +41,14 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMem services.AddSingleton(dm); services.AddSingleton(bus); - var cs = $"DataSource=switchpriming-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs index 441b80f8..87b9381d 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs @@ -1,8 +1,8 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; @@ -41,15 +41,14 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMem services.AddSingleton(dm); services.AddSingleton(bus); - var cs = $"DataSource=switchquery-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs index f241d6bf..177f4061 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs @@ -1,8 +1,8 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; @@ -38,15 +38,14 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, IEven services.AddSingleton(dm); services.AddSingleton(); - var cs = $"DataSource=teamchat-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs index 047ef55e..f82d4472 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs @@ -64,9 +64,9 @@ private static PairingNotification StorageMonitorPairing(Guid fpServer, ulong en [Fact] public async Task NewServerPairing_RoutesToCoordinator_PersistsNothing() { - var (context, connection) = TestDb.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = TestDb.Create(); + await using var _ = database; + await using var __ = context; var bus = Substitute.For(); var (handler, coordinator) = CreateHandler(context, bus); @@ -82,9 +82,9 @@ await coordinator.Received(1).HandleDetectedAsync(10UL, 99UL, [Fact] public async Task ExistingServerPairing_AddsStandbyCredential_NoEventNoPrompt() { - var (context, connection) = TestDb.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = TestDb.Create(); + await using var _ = database; + await using var __ = context; var bus = Substitute.For(); var (handler, coordinator) = CreateHandler(context, bus); await SeedServerAsync(context); @@ -102,9 +102,9 @@ await coordinator.DidNotReceive().HandleDetectedAsync(Arg.Any(), Arg.Any< [Fact] public async Task ExistingServerPairing_BackfillsFacepunchServerId() { - var (context, connection) = TestDb.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = TestDb.Create(); + await using var _ = database; + await using var __ = context; var bus = Substitute.For(); var (handler, _) = CreateHandler(context, bus); await SeedServerAsync(context); // no Facepunch id yet @@ -118,9 +118,9 @@ public async Task ExistingServerPairing_BackfillsFacepunchServerId() [Fact] public async Task EntityPairing_KnownServer_PublishesSwitchPairedEvent() { - var (context, connection) = TestDb.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = TestDb.Create(); + await using var _ = database; + await using var __ = context; var bus = Substitute.For(); var (handler, _) = CreateHandler(context, bus); var server = await SeedServerAsync(context, FpServer); @@ -135,9 +135,9 @@ await bus.Received(1).PublishAsync( [Fact] public async Task EntityPairing_UnknownServer_DropsAndCreatesNothing() { - var (context, connection) = TestDb.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = TestDb.Create(); + await using var _ = database; + await using var __ = context; var bus = Substitute.For(); var (handler, _) = CreateHandler(context, bus); @@ -150,9 +150,9 @@ public async Task EntityPairing_UnknownServer_DropsAndCreatesNothing() [Fact] public async Task EntityPairing_Alarm_PublishesAlarmPairedEvent_NotSwitch() { - var (context, connection) = TestDb.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = TestDb.Create(); + await using var _ = database; + await using var __ = context; var bus = Substitute.For(); var (handler, _) = CreateHandler(context, bus); var server = await SeedServerAsync(context, FpServer); @@ -168,9 +168,9 @@ await bus.Received(1).PublishAsync( [Fact] public async Task EntityPairing_StorageMonitor_PublishesStorageMonitorPairedEvent_NotSwitch() { - var (context, connection) = TestDb.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = TestDb.Create(); + await using var _ = database; + await using var __ = context; var bus = Substitute.For(); var (handler, _) = CreateHandler(context, bus); var server = await SeedServerAsync(context, FpServer); diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs index 6125255f..76806d11 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs @@ -1,11 +1,10 @@ using System.Diagnostics; using System.Security.Cryptography; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ExceptionExtensions; +using Persistord.Testing; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; @@ -37,12 +36,13 @@ private static Harness CreateHarness(FakePairingSource source, PairingOptions? o services.AddSingleton(protector); services.AddSingleton(); - // Keep one open in-memory SQLite connection (singleton) and give each scope its OWN context. - var (seed, connection) = TestDb.Create(); + // Keep one private in-memory database (singleton) and give each scope its OWN context over the + // connection it holds open — Options() reuses that connection rather than the connection string. + var (seed, database) = TestDb.Create(); seed.Dispose(); - services.AddSingleton(connection); + services.AddSingleton(database); services.AddScoped(sp => new BotDbContext( - new DbContextOptionsBuilder().UseSqlite(sp.GetRequiredService()).Options)); + sp.GetRequiredService().Options())); services.AddScoped(); var handler = Substitute.For(); services.AddScoped(_ => handler); diff --git a/tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj b/tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj index 3c6cb993..e506ddfd 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj +++ b/tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs index c181514e..880ad47d 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs @@ -35,7 +35,7 @@ private static PairingNotification ServerPairing(string ip = "1.2.3.4", int port private static Harness Create(ulong? channelId = 777UL) { - var (context, connection) = TestDb.Create(); + var (context, database) = TestDb.Create(); var workspace = Substitute.For(); workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en"); @@ -61,15 +61,15 @@ private static Harness Create(ulong? channelId = 777UL) new ServerPairingPromptRenderer(new ResxLocalizer()), notifier, bus, NullLogger.Instance); - return new Harness(coordinator, context, connection, locator, poster, notifier, bus); + return new Harness(coordinator, context, database, locator, poster, notifier, bus); } [Fact] public async Task Detected_posts_prompt_holds_pending_and_persists_nothing() { var h = Create(); - await using var _ = h.Context; - await using var __ = h.Connection; + await using var _ = h.Database; + await using var __ = h.Context; await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None); @@ -85,8 +85,8 @@ await h.Poster.Received(1).EnsureAsync(777UL, null, Arg.Any(), 900UL, Arg.Any(), 901UL, Arg.Any(), Arg.Any(), public async Task Accept_persists_publishes_event_once_and_edits_prompt() { var h = Create(); - await using var _ = h.Context; - await using var __ = h.Connection; + await using var _ = h.Database; + await using var __ = h.Context; await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None); var outcome = await h.Coordinator.TryAcceptAsync(10UL, "1.2.3.4", 28015, CancellationToken.None); @@ -178,8 +178,8 @@ await h.Poster.Received(1).EnsureAsync(777UL, 900UL, Arg.Any(TaskCreationOptions.RunContinuationsAsynchronously); h.Poster.EnsureAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) @@ -237,8 +237,8 @@ await h.Poster.Received(1).EnsureAsync(Arg.Any(), 900UL, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns((ulong?)null); @@ -264,8 +264,8 @@ await h.Poster.Received(2).EnsureAsync(Arg.Any(), Arg.Any(), public async Task Accept_without_setup_channel_still_persists_and_skips_edit() { var h = Create(); - await using var _ = h.Context; - await using var __ = h.Connection; + await using var _ = h.Database; + await using var __ = h.Context; await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None); h.Locator.GetChannelIdAsync(Arg.Any(), Arg.Any()).Returns((ulong?)null); @@ -290,8 +290,8 @@ await h.Poster.Received(1).EnsureAsync(Arg.Any(), Arg.Any(), public async Task Dismiss_clears_pending_once() { var h = Create(); - await using var _ = h.Context; - await using var __ = h.Connection; + await using var _ = h.Database; + await using var __ = h.Context; await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None); Assert.True(h.Coordinator.TryDismiss(10UL, "1.2.3.4", 28015)); @@ -302,7 +302,7 @@ public async Task Dismiss_clears_pending_once() private sealed record Harness( ServerPairingCoordinator Coordinator, BotDbContext Context, - Microsoft.Data.Sqlite.SqliteConnection Connection, + Persistord.Testing.SqliteTestDatabase Database, ISetupChannelLocator Locator, ISetupChannelPoster Poster, IOwnerNotifier Notifier, diff --git a/tests/RustPlusBot.Features.Pairing.Tests/TestDb.cs b/tests/RustPlusBot.Features.Pairing.Tests/TestDb.cs index 8343b303..ef301429 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/TestDb.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/TestDb.cs @@ -1,19 +1,16 @@ -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; +using Persistord.Testing; using RustPlusBot.Persistence; namespace RustPlusBot.Features.Pairing.Tests; -/// Creates a BotDbContext over a private in-memory SQLite connection kept open for the test. +/// Creates a BotDbContext over a private in-memory SQLite database that lives as long as the test. internal static class TestDb { - public static (BotDbContext Context, SqliteConnection Connection) Create() + /// Builds the context and its database, applying the committed migrations. + /// The context and the database backing it. Dispose both. + public static (BotDbContext Context, SqliteTestDatabase Database) Create() { - var connection = new SqliteConnection("DataSource=:memory:"); - connection.Open(); - var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; - var context = new BotDbContext(options); - context.Database.Migrate(); - return (context, connection); + var database = SqliteTestDatabase.Private(); + return (database.CreateContext(options => new BotDbContext(options)), database); } } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/AlarmChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/AlarmChannelLocatorTests.cs index f626be31..d9e81fcb 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/AlarmChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/AlarmChannelLocatorTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; @@ -19,16 +19,15 @@ private static (AlarmChannelLocator Locator, ServiceProvider Provider, string Co var clock = Substitute.For(); clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - var cs = $"DataSource=alarm-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); var services = new ServiceCollection(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddSingleton(clock); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/CachingChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/CachingChannelLocatorTests.cs index 47175d7b..ec6c8f8c 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/CachingChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/CachingChannelLocatorTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; @@ -28,16 +28,15 @@ private static (TestChannelLocator Locator, ServiceProvider Provider, string Con var clock = Substitute.For(); clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - var cs = $"DataSource=caching-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); var services = new ServiceCollection(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddSingleton(clock); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/EventChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/EventChannelLocatorTests.cs index 6d7bbe68..eade1113 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/EventChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/EventChannelLocatorTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; @@ -19,16 +19,15 @@ private static (EventChannelLocator Locator, ServiceProvider Provider, string Co var clock = Substitute.For(); clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - var cs = $"DataSource=event-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); var services = new ServiceCollection(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddSingleton(clock); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/MapChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/MapChannelLocatorTests.cs index 633a121b..955ccfcc 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/MapChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/MapChannelLocatorTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; @@ -19,16 +19,15 @@ private static (MapChannelLocator Locator, ServiceProvider Provider, string Conn var clock = Substitute.For(); clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - var cs = $"DataSource=map-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); var services = new ServiceCollection(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddSingleton(clock); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/PlayerEventChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/PlayerEventChannelLocatorTests.cs index cf149005..1f25e807 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/PlayerEventChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/PlayerEventChannelLocatorTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; @@ -20,16 +20,15 @@ private static (PlayerEventChannelLocator Locator, ServiceProvider Provider, str var clock = Substitute.For(); clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - var cs = $"DataSource=player-event-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); var services = new ServiceCollection(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddSingleton(clock); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/SetupChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/SetupChannelLocatorTests.cs index 1bd3ce90..f51c84ea 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/SetupChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/SetupChannelLocatorTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; @@ -19,16 +19,15 @@ private static (SetupChannelLocator Locator, ServiceProvider Provider, string Co var clock = Substitute.For(); clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - var cs = $"DataSource=setup-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); var services = new ServiceCollection(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddSingleton(clock); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/StorageMonitorChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/StorageMonitorChannelLocatorTests.cs index 25867a18..6cb00f38 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/StorageMonitorChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/StorageMonitorChannelLocatorTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; @@ -20,16 +20,15 @@ private static (StorageMonitorChannelLocator Locator, ServiceProvider Provider, var clock = Substitute.For(); clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - var cs = $"DataSource=storagemonitor-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); var services = new ServiceCollection(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddSingleton(clock); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/SwitchChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/SwitchChannelLocatorTests.cs index 0ad446c9..e2485a34 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/SwitchChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/SwitchChannelLocatorTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; @@ -19,16 +19,15 @@ private static (SwitchChannelLocator Locator, ServiceProvider Provider, string C var clock = Substitute.For(); clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - var cs = $"DataSource=switch-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); var services = new ServiceCollection(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddSingleton(clock); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs index 72fce1a8..343f4594 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; @@ -19,16 +19,15 @@ private static (TeamChatChannelLocator Locator, ServiceProvider Provider, string var clock = Substitute.For(); clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - var cs = $"DataSource=locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } + // One shared-cache in-memory database several connections can open independently, so a + // background loop and the test never run concurrent commands on one connection. Creating + // the first context is what applies the migrations. + var database = SqliteTestDatabase.Shared(); + var cs = database.ConnectionString; + database.CreateContext(options => new BotDbContext(options)).Dispose(); var services = new ServiceCollection(); - services.AddSingleton(keepAlive); + services.AddSingleton(database); services.AddSingleton(clock); services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj b/tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj index 6dca572a..07840a04 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj +++ b/tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs index 992b7a57..c51ae0c1 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs @@ -1,14 +1,14 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Domain.Clans; using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; -using RustPlusBot.Domain.Entities; -using RustPlusBot.Domain.Events; using RustPlusBot.Domain.Guilds; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Switches; +using RustPlusBot.Domain.Vending; using RustPlusBot.Domain.Workspace; using RustPlusBot.Features.Workspace.Gateway; using RustPlusBot.Features.Workspace.Reconciler; @@ -21,21 +21,11 @@ namespace RustPlusBot.Features.Workspace.Tests.Teardown; public sealed class GuildPurgeServiceTests { - private static BotDbContext NewContext(SqliteConnection connection) - { - var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; - var context = new BotDbContext(options); - context.Database.Migrate(); - return context; - } - [Fact] public async Task PurgeGuild_RemovesTargetGuildRows_AndLeavesOtherGuildIntact() { - var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - await using var _ = connection; - await using var context = NewContext(connection); + await using var database = SqliteTestDatabase.Private(); + await using var context = database.CreateContext(options => new BotDbContext(options)); var serverA = new RustServer { @@ -54,18 +44,6 @@ public async Task PurgeGuild_RemovesTargetGuildRows_AndLeavesOtherGuildIntact() { RustServerId = serverA.Id, GuildId = 1, Status = ConnectionStatus.Connected }); - context.EventSubscriptions.Add(new EventSubscription - { - GuildId = 1, RustServerId = serverA.Id, EventKey = "cargo" - }); - context.EventSubscriptions.Add(new EventSubscription - { - GuildId = 2, RustServerId = serverB.Id, EventKey = "cargo" - }); - context.PairedEntities.Add(new PairedEntity - { - GuildId = 1, RustServerId = serverA.Id, EntityId = 5, Name = "dev" - }); context.GuildSettings.Add(new GuildSettings { GuildId = 1, Culture = "en" @@ -82,6 +60,21 @@ public async Task PurgeGuild_RemovesTargetGuildRows_AndLeavesOtherGuildIntact() { GuildId = 2, OwnerUserId = 200, ProtectedFcmCredentials = "y" }); + + // Two tables the old purge never named: one that cascaded off RustServer and one that is + // reached only through IGuildScoped. Both go because they declare the interface. + context.VendingGridTracks.Add(new VendingGridTrack + { + GuildId = 1, ServerId = serverA.Id, Grid = "D7", RegisteredBySteamId = 5 + }); + context.ClanPlayerNames.Add(new ClanPlayerName + { + GuildId = 1, ServerId = serverA.Id, SteamId = 5, Name = "Alice" + }); + context.ClanPlayerNames.Add(new ClanPlayerName + { + GuildId = 2, ServerId = serverB.Id, SteamId = 6, Name = "Bob" + }); await context.SaveChangesAsync(); // Real teardown over fake Discord I/O, sharing the lock the purge holds. An empty category set @@ -103,16 +96,16 @@ public async Task PurgeGuild_RemovesTargetGuildRows_AndLeavesOtherGuildIntact() Assert.Empty(await context.RustServers.Where(s => s.GuildId == 1).ToListAsync()); Assert.Empty(await context.SmartSwitches.ToListAsync()); Assert.Empty(await context.ConnectionStates.ToListAsync()); - Assert.Empty(await context.EventSubscriptions.Where(e => e.GuildId == 1).ToListAsync()); - Assert.Empty(await context.PairedEntities.Where(p => p.GuildId == 1).ToListAsync()); Assert.Empty(await context.GuildSettings.Where(g => g.GuildId == 1).ToListAsync()); Assert.Empty(await context.FcmRegistrations.Where(f => f.GuildId == 1).ToListAsync()); + Assert.Empty(await context.VendingGridTracks.ToListAsync()); + Assert.Empty(await context.ClanPlayerNames.Where(n => n.GuildId == 1).ToListAsync()); // Guild 2 untouched. Assert.Single(await context.RustServers.Where(s => s.GuildId == 2).ToListAsync()); - Assert.Single(await context.EventSubscriptions.Where(e => e.GuildId == 2).ToListAsync()); Assert.Single(await context.GuildSettings.Where(g => g.GuildId == 2).ToListAsync()); Assert.Single(await context.FcmRegistrations.Where(f => f.GuildId == 2).ToListAsync()); + Assert.Single(await context.ClanPlayerNames.Where(n => n.GuildId == 2).ToListAsync()); } /// @@ -123,10 +116,8 @@ public async Task PurgeGuild_RemovesTargetGuildRows_AndLeavesOtherGuildIntact() [Fact] public async Task PurgeGuild_StopsEachServersConnection_WhileItsRowStillExists() { - var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - await using var _ = connection; - await using var context = NewContext(connection); + await using var database = SqliteTestDatabase.Private(); + await using var context = database.CreateContext(options => new BotDbContext(options)); var server = new RustServer { diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/ServerPurgeServiceTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/ServerPurgeServiceTests.cs index f77ce467..2b0e813f 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/ServerPurgeServiceTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/ServerPurgeServiceTests.cs @@ -1,7 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using NSubstitute; -using RustPlusBot.Abstractions.Time; +using Persistord.Testing; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; using RustPlusBot.Features.Workspace.Gateway; @@ -99,12 +98,8 @@ public async Task PurgeServer_TearsDownTheScope_BeforeDeletingTheRow() [Fact] public async Task PurgeServer_DeletesTheDiscordChannels_EvenThoughTheRecordsCascadeWithTheRow() { - var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - await using var _ = connection; - var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; - await using var context = new BotDbContext(options); - await context.Database.MigrateAsync(); + await using var database = SqliteTestDatabase.Private(); + await using var context = database.CreateContext(options => new BotDbContext(options)); var server = new RustServer { @@ -126,10 +121,8 @@ public async Task PurgeServer_DeletesTheDiscordChannels_EvenThoughTheRecordsCasc await context.SaveChangesAsync(); var gateway = Substitute.For(); - var clock = Substitute.For(); - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); var provisioningLock = new ProvisioningLock(); - var teardown = new WorkspaceTeardownService(gateway, new WorkspaceStore(context, clock), provisioningLock); + var teardown = new WorkspaceTeardownService(gateway, new WorkspaceStore(context), provisioningLock); var sut = new ServerPurgeService(new ServerService(context), teardown, provisioningLock); var removed = await sut.RemoveServerAsync(GuildId, server.Id); diff --git a/tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs index 64313845..6b1bb9c4 100644 --- a/tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs @@ -1,8 +1,6 @@ using System.Globalization; -using Microsoft.Data.Sqlite; -using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Persistence.Alarms; @@ -10,13 +8,12 @@ namespace RustPlusBot.Persistence.Tests.Alarms; public sealed class AlarmStoreTests { - private static (AlarmStore Store, BotDbContext Context, SqliteConnection Conn) Create( + private static (AlarmStore Store, BotDbContext Context, SqliteTestDatabase Db) Create( DateTimeOffset? now = null) { - var (context, connection) = SqliteContextFixture.Create(); - var clock = Substitute.For(); - clock.UtcNow.Returns(now ?? DateTimeOffset.UnixEpoch); - return (new AlarmStore(context, clock), context, connection); + var (context, database) = SqliteContextFixture.Create( + new FixedTimeProvider(now ?? DateTimeOffset.UnixEpoch)); + return (new AlarmStore(context), context, database); } private static async Task SeedServerAsync(BotDbContext context, string ip = "1.1.1.1", string name = "S") @@ -45,7 +42,7 @@ public async Task Add_then_Get_round_trips_fields() Assert.Equal(added.Id, loaded.Id); Assert.Equal("Alarm 42", loaded.Name); Assert.Equal(7UL, loaded.PairedByUserId); - Assert.Equal(DateTimeOffset.UnixEpoch, loaded.CreatedUtc); + Assert.Equal(DateTimeOffset.UnixEpoch, loaded.CreatedAt); Assert.False(loaded.PingEveryone); Assert.False(loaded.RelayToTeamChat); Assert.False(loaded.LastIsActive); @@ -91,29 +88,26 @@ public async Task ListByServer_returns_oldest_first() var t1 = t0.AddMinutes(1); var t2 = t0.AddMinutes(2); - var (context0, conn0) = SqliteContextFixture.Create(); - await using var _conn = conn0; + var time = new FixedTimeProvider(t2); + var (context0, database) = SqliteContextFixture.Create(time); + await using var _db = database; await using var _ctx = context0; var serverId = await SeedServerAsync(context0); - var clock0 = Substitute.For(); - clock0.UtcNow.Returns(t2); - var store0 = new AlarmStore(context0, clock0); - await store0.AddAsync(10UL, serverId, 3UL, "C", 7UL); + var store = new AlarmStore(context0); + await store.AddAsync(10UL, serverId, 3UL, "C", 7UL); - clock0.UtcNow.Returns(t0); - var store1 = new AlarmStore(context0, clock0); - await store1.AddAsync(10UL, serverId, 1UL, "A", 7UL); + time.Now = t0; + await store.AddAsync(10UL, serverId, 1UL, "A", 7UL); - clock0.UtcNow.Returns(t1); - var store2 = new AlarmStore(context0, clock0); - await store2.AddAsync(10UL, serverId, 2UL, "B", 7UL); + time.Now = t1; + await store.AddAsync(10UL, serverId, 2UL, "B", 7UL); - var list = await store0.ListByServerAsync(10UL, serverId); + var list = await store.ListByServerAsync(10UL, serverId); Assert.Equal(3, list.Count); - Assert.Equal(t0, list[0].CreatedUtc); - Assert.Equal(t1, list[1].CreatedUtc); - Assert.Equal(t2, list[2].CreatedUtc); + Assert.Equal(t0, list[0].CreatedAt); + Assert.Equal(t1, list[1].CreatedAt); + Assert.Equal(t2, list[2].CreatedAt); } [Fact] diff --git a/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs index 3f080674..0ea5961f 100644 --- a/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs @@ -9,9 +9,9 @@ public sealed class SmartAlarmSchemaTests [Fact] public async Task RemovingServer_CascadeDeletesAlarms() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = new RustServer { @@ -26,7 +26,7 @@ public async Task RemovingServer_CascadeDeletesAlarms() ServerId = server.Id, EntityId = 42UL, Name = "Alarm 42", - CreatedUtc = DateTimeOffset.UtcNow, + CreatedAt = DateTimeOffset.UtcNow, }); await context.SaveChangesAsync(); @@ -39,9 +39,9 @@ public async Task RemovingServer_CascadeDeletesAlarms() [Fact] public async Task DuplicateEntityForSameServer_IsRejected() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = new RustServer { @@ -56,7 +56,7 @@ public async Task DuplicateEntityForSameServer_IsRejected() ServerId = server.Id, EntityId = 7UL, Name = "a", - CreatedUtc = DateTimeOffset.UtcNow + CreatedAt = DateTimeOffset.UtcNow }); await context.SaveChangesAsync(); context.SmartAlarms.Add(new SmartAlarm @@ -65,7 +65,7 @@ public async Task DuplicateEntityForSameServer_IsRejected() ServerId = server.Id, EntityId = 7UL, Name = "b", - CreatedUtc = DateTimeOffset.UtcNow + CreatedAt = DateTimeOffset.UtcNow }); await Assert.ThrowsAsync(() => context.SaveChangesAsync()); diff --git a/tests/RustPlusBot.Persistence.Tests/BotDbContextTests.cs b/tests/RustPlusBot.Persistence.Tests/BotDbContextTests.cs index 7ed0eab7..fa98782b 100644 --- a/tests/RustPlusBot.Persistence.Tests/BotDbContextTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/BotDbContextTests.cs @@ -1,6 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; +using Persistord.Core.Abstractions; +using Persistord.Testing; using RustPlusBot.Domain.Devices; using RustPlusBot.Domain.Guilds; using RustPlusBot.Domain.Servers; @@ -14,9 +16,9 @@ public sealed class BotDbContextTests [Fact] public async Task GuildSettings_PreservesSuppliedSnowflakePrimaryKey() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; const ulong guildId = 1357924680135792468UL; context.GuildSettings.Add(new GuildSettings @@ -33,9 +35,9 @@ public async Task GuildSettings_PreservesSuppliedSnowflakePrimaryKey() [Fact] public void PairedDeviceEntity_IsNotAnEntityType_SoTheDeviceTablesNeverCollapseIntoOne() { - var (context, connection) = SqliteContextFixture.Create(); - using var _ = context; - using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + using var _ = database; + using var __ = context; // The base is code-sharing only. If it ever entered the model, EF would map SmartSwitch and // SmartStorageMonitor as one table-per-hierarchy table and the two device tables would merge. @@ -55,12 +57,51 @@ public void PairedDeviceEntity_IsNotAnEntityType_SoTheDeviceTablesNeverCollapseI Assert.NotEqual(smartSwitch.GetTableName(), storageMonitor.GetTableName()); } + /// + /// Pins the shape Persistord's conventions and PurgeGuildAsync depend on: the guild key is + /// caller-supplied and stored as a long, a device row is uniquely identified within its server, + /// and it cascades with the server it hangs off. + /// + [Fact] + public void Model_KeepsTheShapePersistordsConventionsAssume() + { + var (context, database) = SqliteContextFixture.Create(); + using var _ = database; + using var __ = context; + + context.AssertSnowflakeKey(); + context.AssertUniqueIndex(nameof(SmartSwitch.GuildId), nameof(SmartSwitch.ServerId), + nameof(SmartSwitch.EntityId)); + context.AssertCascade(); + } + + /// + /// Every mapped entity type is guild-scoped, which is what makes PurgeGuildAsync a complete + /// teardown: a table that opted out would silently survive a guild purge. + /// + [Fact] + public void EveryMappedEntity_IsGuildScoped() + { + var (context, database) = SqliteContextFixture.Create(); + using var _ = database; + using var __ = context; + + var unscoped = context.Model.GetEntityTypes() + .Where(e => !e.IsOwned()) + .Select(e => e.ClrType) + .Where(t => !typeof(IGuildScoped).IsAssignableFrom(t)) + .Select(t => t.Name) + .ToList(); + + Assert.Empty(unscoped); + } + [Fact] public async Task RustServer_RoundTrips_WithSnowflakeGuildId() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; const ulong guildId = 1234567890123456789UL; // larger than long.MaxValue/2; exercises ulong<->long context.RustServers.Add(new RustServer diff --git a/tests/RustPlusBot.Persistence.Tests/Chat/ChatWebhookStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Chat/ChatWebhookStoreTests.cs new file mode 100644 index 00000000..d26cd206 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Chat/ChatWebhookStoreTests.cs @@ -0,0 +1,156 @@ +using System.Security.Cryptography; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Persistence.Chat; + +namespace RustPlusBot.Persistence.Tests.Chat; + +public sealed class ChatWebhookStoreTests +{ + private const ulong Guild = 10UL; + + private const ulong Channel = 777UL; + + private const string Key = "RustPlusBot TeamChat"; + + private static ICredentialProtector PrefixingProtector() + { + var protector = Substitute.For(); + protector.Protect(Arg.Any()).Returns(call => "enc:" + call.Arg()); + protector.Unprotect(Arg.Any()).Returns(call => call.Arg()["enc:".Length..]); + return protector; + } + + [Fact] + public async Task Save_then_Get_round_trips_the_webhook_and_stores_the_token_protected() + { + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; + var store = new ChatWebhookStore(context, PrefixingProtector()); + + await store.SaveAsync(Guild, Channel, Key, 4242UL, "s3cret"); + + var webhook = await store.GetAsync(Guild, Channel, Key); + Assert.NotNull(webhook); + Assert.Equal(4242UL, webhook.Value.Id); + Assert.Equal("s3cret", webhook.Value.Token); + + // The column holds ciphertext, not the token as handed in. + var row = await context.ChatWebhooks.SingleAsync(); + Assert.Equal("enc:s3cret", row.Token); + Assert.Equal(Channel, row.ChannelDiscordId); + } + + [Fact] + public async Task Get_returns_null_when_nothing_was_recorded() + { + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; + var store = new ChatWebhookStore(context, PrefixingProtector()); + + Assert.Null(await store.GetAsync(Guild, Channel, Key)); + } + + /// + /// Re-recording the same channel and kind replaces the row rather than adding a second one: a + /// re-created webhook must not leave the old id behind for the unique index to reject. + /// + [Fact] + public async Task Saving_again_replaces_the_recorded_webhook() + { + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; + var store = new ChatWebhookStore(context, PrefixingProtector()); + + await store.SaveAsync(Guild, Channel, Key, 1UL, "first"); + await store.SaveAsync(Guild, Channel, Key, 2UL, "second"); + + var webhook = await store.GetAsync(Guild, Channel, Key); + Assert.Equal(2UL, webhook!.Value.Id); + Assert.Equal("second", webhook.Value.Token); + Assert.Single(await context.ChatWebhooks.ToListAsync()); + } + + [Fact] + public async Task Records_are_kept_apart_per_channel_and_per_kind() + { + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; + var store = new ChatWebhookStore(context, PrefixingProtector()); + + await store.SaveAsync(Guild, Channel, Key, 1UL, "team"); + await store.SaveAsync(Guild, Channel, "RustPlusBot ClanChat", 2UL, "clan"); + await store.SaveAsync(Guild, 888UL, Key, 3UL, "other channel"); + await store.SaveAsync(20UL, Channel, Key, 4UL, "other guild"); + + Assert.Equal(1UL, (await store.GetAsync(Guild, Channel, Key))!.Value.Id); + Assert.Equal(2UL, (await store.GetAsync(Guild, Channel, "RustPlusBot ClanChat"))!.Value.Id); + Assert.Equal(3UL, (await store.GetAsync(Guild, 888UL, Key))!.Value.Id); + Assert.Equal(4UL, (await store.GetAsync(20UL, Channel, Key))!.Value.Id); + } + + [Fact] + public async Task Forget_drops_only_that_record() + { + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; + var store = new ChatWebhookStore(context, PrefixingProtector()); + + await store.SaveAsync(Guild, Channel, Key, 1UL, "team"); + await store.SaveAsync(Guild, 888UL, Key, 2UL, "other channel"); + + await store.ForgetAsync(Guild, Channel, Key); + + Assert.Null(await store.GetAsync(Guild, Channel, Key)); + Assert.NotNull(await store.GetAsync(Guild, 888UL, Key)); + } + + /// + /// A token the current Data Protection key ring cannot read is dead weight — the relay can neither + /// post through it nor repair it. Reporting "none" and dropping the row lets the next post + /// re-resolve the webhook instead of failing every line from then on. + /// + [Fact] + public async Task An_unreadable_token_reports_no_record_and_drops_it() + { + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; + + var protector = Substitute.For(); + protector.Protect(Arg.Any()).Returns(call => call.Arg()); + protector.Unprotect(Arg.Any()).Returns(_ => throw new CryptographicException("key ring rotated")); + var store = new ChatWebhookStore(context, protector); + + await store.SaveAsync(Guild, Channel, Key, 1UL, "unreadable"); + + Assert.Null(await store.GetAsync(Guild, Channel, Key)); + Assert.Empty(await context.ChatWebhooks.ToListAsync()); + } + + /// + /// The record is a guild-scoped managed resource, so it is stamped like every other row the bot + /// owns and is swept up by the guild purge without anyone naming the table. + /// + [Fact] + public async Task Recorded_webhooks_are_stamped_and_guild_scoped() + { + var (context, database) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); + await using var _ = database; + await using var __ = context; + var store = new ChatWebhookStore(context, PrefixingProtector()); + + await store.SaveAsync(Guild, Channel, Key, 1UL, "team"); + + var row = await context.ChatWebhooks.SingleAsync(); + Assert.Equal(Guild, row.GuildId); + Assert.Equal(DateTimeOffset.UnixEpoch, row.CreatedAt); + Assert.Equal(DateTimeOffset.UnixEpoch, row.UpdatedAt); + } +} diff --git a/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs index 03393681..08178ed4 100644 --- a/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs @@ -1,8 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Clans; using RustPlusBot.Domain.Servers; using RustPlusBot.Persistence.Clans; @@ -12,12 +10,11 @@ namespace RustPlusBot.Persistence.Tests; /// Unit tests for . public sealed class ClanStoreTests { - private static (ClanStore Store, BotDbContext Context, SqliteConnection Conn, IClock Clock) Create() + private static (ClanStore Store, BotDbContext Context, SqliteTestDatabase Db, FixedTimeProvider Time) Create() { - var (context, connection) = SqliteContextFixture.Create(); - var clock = Substitute.For(); - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - return (new ClanStore(context, clock), context, connection, clock); + var time = new FixedTimeProvider(DateTimeOffset.UnixEpoch); + var (context, database) = SqliteContextFixture.Create(time); + return (new ClanStore(context, time), context, database, time); } private static async Task SeedServerAsync(BotDbContext context, ulong guildId = 10UL) @@ -276,19 +273,19 @@ public async Task Recording_a_name_twice_updates_rather_than_duplicating() [Fact] public async Task Recording_the_same_name_again_skips_the_write() { - var (store, context, conn, clock) = Create(); + var (store, context, conn, time) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); await store.RecordNameAsync(10UL, serverId, 111UL, "Alice"); - var firstUpdatedUtc = (await context.ClanPlayerNames.SingleAsync(n => n.SteamId == 111UL)).UpdatedUtc; + var firstUpdatedAt = (await context.ClanPlayerNames.SingleAsync(n => n.SteamId == 111UL)).UpdatedAt; - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch.AddMinutes(5)); + time.Now = DateTimeOffset.UnixEpoch.AddMinutes(5); await store.RecordNameAsync(10UL, serverId, 111UL, "Alice"); var row = await context.ClanPlayerNames.SingleAsync(n => n.SteamId == 111UL); - Assert.Equal(firstUpdatedUtc, row.UpdatedUtc); + Assert.Equal(firstUpdatedAt, row.UpdatedAt); Assert.Single(await context.ClanPlayerNames.ToListAsync()); } @@ -305,7 +302,7 @@ public async Task Name_lookup_ignores_a_row_whose_guild_id_belongs_to_another_gu ServerId = serverId, SteamId = 111UL, Name = "Alice", - UpdatedUtc = DateTimeOffset.UnixEpoch + UpdatedAt = DateTimeOffset.UnixEpoch }); await context.SaveChangesAsync(); @@ -317,7 +314,7 @@ public async Task Name_lookup_ignores_a_row_whose_guild_id_belongs_to_another_gu [Fact] public async Task Recording_a_name_heals_a_stale_guild_id_even_when_the_name_is_unchanged() { - var (store, context, conn, clock) = Create(); + var (store, context, conn, time) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); @@ -327,23 +324,23 @@ public async Task Recording_a_name_heals_a_stale_guild_id_even_when_the_name_is_ ServerId = serverId, SteamId = 111UL, Name = "Alice", - UpdatedUtc = DateTimeOffset.UnixEpoch + UpdatedAt = DateTimeOffset.UnixEpoch }); await context.SaveChangesAsync(); - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch.AddMinutes(5)); + time.Now = DateTimeOffset.UnixEpoch.AddMinutes(5); await store.RecordNameAsync(10UL, serverId, 111UL, "Alice"); var row = await context.ClanPlayerNames.SingleAsync(n => n.SteamId == 111UL); Assert.Equal(10UL, row.GuildId); - Assert.Equal(DateTimeOffset.UnixEpoch.AddMinutes(5), row.UpdatedUtc); + Assert.Equal(DateTimeOffset.UnixEpoch.AddMinutes(5), row.UpdatedAt); Assert.Single(await context.ClanPlayerNames.ToListAsync()); } [Fact] public async Task Saving_a_clan_state_heals_a_stale_guild_id_instead_of_throwing() { - var (store, context, conn, clock) = Create(); + var (store, context, conn, time) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); @@ -353,7 +350,7 @@ public async Task Saving_a_clan_state_heals_a_stale_guild_id_instead_of_throwing }); await context.SaveChangesAsync(); - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch.AddMinutes(5)); + time.Now = DateTimeOffset.UnixEpoch.AddMinutes(5); await store.SaveAsync(10UL, serverId, CreateSnapshot()); var row = await context.ClanStates.SingleAsync(s => s.ServerId == serverId); diff --git a/tests/RustPlusBot.Persistence.Tests/Commands/MuteStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Commands/MuteStoreTests.cs index 01b457e1..a3bcec8f 100644 --- a/tests/RustPlusBot.Persistence.Tests/Commands/MuteStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Commands/MuteStoreTests.cs @@ -1,4 +1,4 @@ -using Microsoft.Data.Sqlite; +using Persistord.Testing; using RustPlusBot.Domain.Servers; using RustPlusBot.Persistence.Commands; @@ -6,10 +6,10 @@ namespace RustPlusBot.Persistence.Tests.Commands; public sealed class MuteStoreTests { - private static (MuteStore Store, BotDbContext Context, SqliteConnection Conn) Create() + private static (MuteStore Store, BotDbContext Context, SqliteTestDatabase Db) Create() { - var (context, connection) = SqliteContextFixture.Create(); - return (new MuteStore(context), context, connection); + var (context, database) = SqliteContextFixture.Create(); + return (new MuteStore(context), context, database); } private static async Task SeedServerAsync(BotDbContext context) diff --git a/tests/RustPlusBot.Persistence.Tests/Commands/ServerCommandSettingsSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Commands/ServerCommandSettingsSchemaTests.cs index 483bea53..dd5c6f00 100644 --- a/tests/RustPlusBot.Persistence.Tests/Commands/ServerCommandSettingsSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Commands/ServerCommandSettingsSchemaTests.cs @@ -9,9 +9,9 @@ public sealed class ServerCommandSettingsSchemaTests [Fact] public async Task ServerCommandSettings_RoundTrips_PrefixAndMuted() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = new RustServer { @@ -34,9 +34,9 @@ public async Task ServerCommandSettings_RoundTrips_PrefixAndMuted() [Fact] public async Task RemovingServer_CascadeDeletesItsCommandSettings() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = new RustServer { diff --git a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs index 801343a6..3c5b7275 100644 --- a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs @@ -9,9 +9,9 @@ public sealed class ConnectionStateSchemaTests [Fact] public async Task ConnectionState_RoundTrips_StatusAndPlayerCount() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = new RustServer { @@ -39,9 +39,9 @@ public async Task ConnectionState_RoundTrips_StatusAndPlayerCount() [Fact] public async Task ConnectionState_RoundTrips_NullPlayerCount() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = new RustServer { @@ -66,9 +66,9 @@ public async Task ConnectionState_RoundTrips_NullPlayerCount() [Fact] public async Task RemovingServer_CascadeDeletesItsConnectionState() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = new RustServer { diff --git a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs index e908740c..d8bdd4d3 100644 --- a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs @@ -1,8 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; -using NSubstitute; -using RustPlusBot.Abstractions.Time; +using Persistord.Testing; using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; using RustPlusBot.Domain.Servers; @@ -12,23 +10,10 @@ namespace RustPlusBot.Persistence.Tests.Connections; public sealed class ConnectionStoreTests { - private static BotDbContext NewContext(SqliteConnection connection, IInterceptor interceptor) + private static (ConnectionStore Store, BotDbContext Context, SqliteTestDatabase Db) Create() { - var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .AddInterceptors(interceptor) - .Options; - var context = new BotDbContext(options); - context.Database.Migrate(); - return context; - } - - private static (ConnectionStore Store, BotDbContext Context, SqliteConnection Conn) Create() - { - var (context, connection) = SqliteContextFixture.Create(); - var clock = Substitute.For(); - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - return (new ConnectionStore(context, clock), context, connection); + var (context, database) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); + return (new ConnectionStore(context), context, database); } private static async Task<(Guid ServerId, Guid CredA, Guid CredB)> SeedServerWithPoolAsync(BotDbContext context) @@ -112,13 +97,11 @@ public async Task UpsertStatus_ForAServerThatIsGone_WritesNothingAndReportsNoCha [Fact] public async Task UpsertStatus_WhenTheServerIsDeletedMidSave_WritesNothingAndReportsNoChange() { - var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - await using var _ = connection; - var deleter = new InterferingWriteInterceptor((ctx, ct) => ctx.Database.ExecuteSqlRawAsync("DELETE FROM RustServers", ct)); - await using var context = NewContext(connection, deleter); + await using var database = SqliteTestDatabase.Private(); + await using var context = database.CreateContext( + options => new BotDbContext(options), deleter); var server = new RustServer { @@ -130,9 +113,7 @@ public async Task UpsertStatus_WhenTheServerIsDeletedMidSave_WritesNothingAndRep // Armed only now: the seed above must survive, and the delete must land between the store's // existence check and its insert. deleter.Arm(); - var clock = Substitute.For(); - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - var store = new ConnectionStore(context, clock); + var store = new ConnectionStore(context); var changed = await store.UpsertStatusAsync( 10UL, server.Id, ConnectionStatus.Unreachable, null, null); @@ -149,25 +130,21 @@ public async Task UpsertStatus_WhenTheServerIsDeletedMidSave_WritesNothingAndRep [Fact] public async Task UpsertStatus_WhenTheSaveFailsForAnotherReason_Throws() { - var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - await using var _ = connection; - var serverId = Guid.Empty; var conflicter = new InterferingWriteInterceptor(async (ctx, ct) => { // A second context over the SAME connection, so the row lands before the outer insert runs. - var options = new DbContextOptionsBuilder() - .UseSqlite(ctx.Database.GetDbConnection()) - .Options; - await using var other = new BotDbContext(options); + await using var other = new BotDbContext( + new DbContextOptionsBuilder().UseSqlite(ctx.Database.GetDbConnection()).Options); other.ConnectionStates.Add(new ConnectionState { RustServerId = serverId, GuildId = 10UL, Status = ConnectionStatus.Connected }); await other.SaveChangesAsync(ct); }); - await using var context = NewContext(connection, conflicter); + await using var database = SqliteTestDatabase.Private(); + await using var context = database.CreateContext( + options => new BotDbContext(options), conflicter); var server = new RustServer { @@ -178,9 +155,7 @@ public async Task UpsertStatus_WhenTheSaveFailsForAnotherReason_Throws() serverId = server.Id; conflicter.Arm(); - var clock = Substitute.For(); - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - var store = new ConnectionStore(context, clock); + var store = new ConnectionStore(context); await Assert.ThrowsAsync(() => store.UpsertStatusAsync(10UL, serverId, ConnectionStatus.Unreachable, null, null)); diff --git a/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs index 0726abe2..955db718 100644 --- a/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs @@ -30,9 +30,9 @@ private static async Task SeedServerAsync(BotDbContext context, ulong guil [Fact] public async Task UpsertFromPairing_FirstCredentialForServer_IsActiveAndProtected() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var serverId = await SeedServerAsync(context, 10UL); var protector = PassThroughProtector(); var store = new CredentialStore(context, protector); @@ -51,9 +51,9 @@ public async Task UpsertFromPairing_FirstCredentialForServer_IsActiveAndProtecte [Fact] public async Task UpsertFromPairing_SecondOwnerForServer_IsStandby() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var serverId = await SeedServerAsync(context, 10UL); var store = new CredentialStore(context, PassThroughProtector()); @@ -70,9 +70,9 @@ await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverId, 2U [Fact] public async Task UpsertFromPairing_SameOwnerAgain_RefreshesTokenAndResetsInvalid() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var serverId = await SeedServerAsync(context, 10UL); var store = new CredentialStore(context, PassThroughProtector()); @@ -94,9 +94,9 @@ public async Task UpsertFromPairing_SameOwnerAgain_RefreshesTokenAndResetsInvali [Fact] public async Task CountForServer_CountsOnlyMatchingGuildAndServer() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var serverA = await SeedServerAsync(context, 10UL); var serverB = await SeedServerAsync(context, 10UL, port: 28016); var serverAGuild20 = await SeedServerAsync(context, 20UL); @@ -117,9 +117,9 @@ await store.UpsertFromPairingAsync(new StoreCredentialRequest(20UL, serverAGuild [Fact] public async Task RemoveForOwner_RemovesOwnersCredsAcrossServers_ReturnsDistinctServerIds_LeavesOthers() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var serverA = await SeedServerAsync(context, 10UL); var serverB = await SeedServerAsync(context, 10UL, port: 28016); var store = new CredentialStore(context, PassThroughProtector()); @@ -147,9 +147,9 @@ await store.UpsertFromPairingAsync(new StoreCredentialRequest(20UL, serverOtherG [Fact] public async Task RemoveForOwner_WhenNothingOwned_ReturnsEmpty() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; await SeedServerAsync(context, 10UL); var store = new CredentialStore(context, PassThroughProtector()); @@ -161,9 +161,9 @@ public async Task RemoveForOwner_WhenNothingOwned_ReturnsEmpty() [Fact] public async Task ListServerIdsForOwner_ReturnsDistinctServers() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var serverA = await SeedServerAsync(context, 10UL); var serverB = await SeedServerAsync(context, 10UL, port: 28016); var store = new CredentialStore(context, PassThroughProtector()); diff --git a/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs index 8b7bcf95..4496e923 100644 --- a/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs @@ -1,7 +1,6 @@ using Microsoft.EntityFrameworkCore; using NSubstitute; using RustPlusBot.Abstractions.Credentials; -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Credentials; using RustPlusBot.Persistence.Credentials; @@ -9,6 +8,8 @@ namespace RustPlusBot.Persistence.Tests.Credentials; public sealed class FcmRegistrationStoreTests { + private static readonly DateTimeOffset Now = new(2026, 6, 15, 0, 0, 0, TimeSpan.Zero); + private static ICredentialProtector PassThroughProtector() { var protector = Substitute.For(); @@ -16,20 +17,13 @@ private static ICredentialProtector PassThroughProtector() return protector; } - private static IClock FixedClock() - { - var clock = Substitute.For(); - clock.UtcNow.Returns(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero)); - return clock; - } - [Fact] public async Task Upsert_StoresProtectedAndActive() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; - var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + var (context, database) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); + await using var _ = database; + await using var __ = context; + var store = new FcmRegistrationStore(context, PassThroughProtector()); var id = await store.UpsertAsync(10UL, 99UL, "{\"a\":1}"); @@ -37,16 +31,16 @@ public async Task Upsert_StoresProtectedAndActive() Assert.Equal(id, saved.Id); Assert.Equal("enc:{\"a\":1}", saved.ProtectedFcmCredentials); Assert.Equal(FcmRegistrationStatus.Active, saved.Status); - Assert.Equal(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero), saved.UpdatedAt); + Assert.Equal(Now, saved.UpdatedAt); } [Fact] public async Task Upsert_SameOwner_RefreshesAndReactivates() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; - var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + var (context, database) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); + await using var _ = database; + await using var __ = context; + var store = new FcmRegistrationStore(context, PassThroughProtector()); var id = await store.UpsertAsync(10UL, 99UL, "old"); await store.SetStatusAsync(id, FcmRegistrationStatus.Expired); @@ -62,10 +56,10 @@ public async Task Upsert_SameOwner_RefreshesAndReactivates() [Fact] public async Task ListActive_ReturnsOnlyActiveAcrossGuilds() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; - var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + var (context, database) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); + await using var _ = database; + await using var __ = context; + var store = new FcmRegistrationStore(context, PassThroughProtector()); await store.UpsertAsync(10UL, 1UL, "a"); var expiredId = await store.UpsertAsync(10UL, 2UL, "b"); @@ -81,26 +75,26 @@ public async Task ListActive_ReturnsOnlyActiveAcrossGuilds() [Fact] public async Task SetStatus_UpdatesStatusAndTimestamp() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; - var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + var (context, database) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); + await using var _ = database; + await using var __ = context; + var store = new FcmRegistrationStore(context, PassThroughProtector()); var id = await store.UpsertAsync(10UL, 99UL, "a"); await store.SetStatusAsync(id, FcmRegistrationStatus.Expired); var saved = await context.FcmRegistrations.SingleAsync(); Assert.Equal(FcmRegistrationStatus.Expired, saved.Status); - Assert.Equal(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero), saved.UpdatedAt); + Assert.Equal(Now, saved.UpdatedAt); } [Fact] public async Task Get_ReturnsRegistrationForOwner_OrNull() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; - var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + var (context, database) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); + await using var _ = database; + await using var __ = context; + var store = new FcmRegistrationStore(context, PassThroughProtector()); await store.UpsertAsync(10UL, 99UL, "a"); diff --git a/tests/RustPlusBot.Persistence.Tests/Credentials/PairingSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Credentials/PairingSchemaTests.cs index 2f5bc289..fc8af100 100644 --- a/tests/RustPlusBot.Persistence.Tests/Credentials/PairingSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Credentials/PairingSchemaTests.cs @@ -9,9 +9,9 @@ public sealed class PairingSchemaTests [Fact] public async Task RemovingServer_CascadeDeletesItsCredentials() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = new RustServer { @@ -38,9 +38,9 @@ public async Task RemovingServer_CascadeDeletesItsCredentials() [Fact] public async Task DuplicateServerEndpoint_ViolatesUniqueIndex() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; context.RustServers.Add(new RustServer { @@ -57,9 +57,9 @@ public async Task DuplicateServerEndpoint_ViolatesUniqueIndex() [Fact] public async Task DuplicateRegistrationForOwner_ViolatesUniqueIndex() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; context.FcmRegistrations.Add(new FcmRegistration { diff --git a/tests/RustPlusBot.Persistence.Tests/Devices/PairedDeviceStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Devices/PairedDeviceStoreTests.cs index 713441e9..9107d88d 100644 --- a/tests/RustPlusBot.Persistence.Tests/Devices/PairedDeviceStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Devices/PairedDeviceStoreTests.cs @@ -1,7 +1,5 @@ -using NSubstitute; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Devices; -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Devices; using RustPlusBot.Domain.Servers; using RustPlusBot.Persistence.StorageMonitors; @@ -17,27 +15,25 @@ public sealed class PairedDeviceStoreTests { [Fact] public Task Switch_store_serves_the_shared_device_surface() => - AssertSharedSurfaceAsync((context, clock) => new SwitchStore(context, clock)); + AssertSharedSurfaceAsync(context => new SwitchStore(context)); [Fact] public Task StorageMonitor_store_serves_the_shared_device_surface() => - AssertSharedSurfaceAsync((context, clock) => new StorageMonitorStore(context, clock)); + AssertSharedSurfaceAsync(context => new StorageMonitorStore(context)); private static async Task AssertSharedSurfaceAsync( - Func> create) + Func> create) where TEntity : PairedDeviceEntity { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = connection; + var (context, database) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); + await using var _ = database; await using var __ = context; - var clock = Substitute.For(); - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - var store = create(context, clock); + var store = create(context); var serverId = await SeedServerAsync(context); Assert.False(await store.ExistsAsync(10UL, serverId, 42UL)); var added = await store.AddAsync(10UL, serverId, 42UL, "Device 42", pairedByUserId: 7UL); - Assert.Equal(DateTimeOffset.UnixEpoch, added.CreatedUtc); + Assert.Equal(DateTimeOffset.UnixEpoch, added.CreatedAt); Assert.True(await store.ExistsAsync(10UL, serverId, 42UL)); await store.SetMessageIdAsync(10UL, serverId, 42UL, 999UL); diff --git a/tests/RustPlusBot.Persistence.Tests/FixedTimeProvider.cs b/tests/RustPlusBot.Persistence.Tests/FixedTimeProvider.cs new file mode 100644 index 00000000..fcb6f344 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/FixedTimeProvider.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Persistence.Tests; + +/// A pinned to an instant the test moves by hand. +/// The instant to report until is set. +public sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider +{ + /// The instant every stamp uses. Assign to advance the clock. + public DateTimeOffset Now { get; set; } = now; + + /// + public override DateTimeOffset GetUtcNow() => Now; +} diff --git a/tests/RustPlusBot.Persistence.Tests/Maintenance/DatabaseMaintenanceServiceTests.cs b/tests/RustPlusBot.Persistence.Tests/Maintenance/DatabaseMaintenanceServiceTests.cs index 75675031..99146041 100644 --- a/tests/RustPlusBot.Persistence.Tests/Maintenance/DatabaseMaintenanceServiceTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Maintenance/DatabaseMaintenanceServiceTests.cs @@ -11,9 +11,9 @@ public sealed class DatabaseMaintenanceServiceTests [Fact] public async Task ClearAllAsync_EmptiesEveryTable_AndKeepsSchema() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var serverA = new RustServer { diff --git a/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs index a5a38350..a3734180 100644 --- a/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs @@ -20,9 +20,9 @@ private static RustServer SeedServer(BotDbContext context) [Fact] public async Task GetAsync_returns_all_on_when_no_row() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var result = await new MapSettingsStore(context).GetAsync(1UL, Guid.NewGuid()); @@ -32,9 +32,9 @@ public async Task GetAsync_returns_all_on_when_no_row() [Fact] public async Task SetLayerAsync_creates_row_and_disables_one_layer_only() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = SeedServer(context); await new MapSettingsStore(context).SetLayerAsync(1UL, server.Id, MapLayer.Monuments, enabled: false); @@ -51,9 +51,9 @@ public async Task SetLayerAsync_creates_row_and_disables_one_layer_only() [Fact] public async Task SetLayerAsync_updates_existing_row() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = SeedServer(context); var store = new MapSettingsStore(context); @@ -67,9 +67,9 @@ public async Task SetLayerAsync_updates_existing_row() [Fact] public async Task SetLayerAsync_can_disable_tunnels_only() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = SeedServer(context); await new MapSettingsStore(context).SetLayerAsync(1UL, server.Id, MapLayer.Tunnels, enabled: false); @@ -82,9 +82,9 @@ public async Task SetLayerAsync_can_disable_tunnels_only() [Fact] public async Task GridStyle_defaults_to_in_game() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var result = await new MapSettingsStore(context).GetAsync(1UL, Guid.NewGuid()); @@ -94,9 +94,9 @@ public async Task GridStyle_defaults_to_in_game() [Fact] public async Task SetGridStyleAsync_creates_row_and_round_trips() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = SeedServer(context); var store = new MapSettingsStore(context); @@ -110,9 +110,9 @@ public async Task SetGridStyleAsync_creates_row_and_round_trips() [Fact] public async Task SetGridStyleAsync_keeps_existing_layer_toggles() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = SeedServer(context); var store = new MapSettingsStore(context); diff --git a/tests/RustPlusBot.Persistence.Tests/Map/ServerMapSettingsSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Map/ServerMapSettingsSchemaTests.cs index b033d7e0..064364bf 100644 --- a/tests/RustPlusBot.Persistence.Tests/Map/ServerMapSettingsSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Map/ServerMapSettingsSchemaTests.cs @@ -9,9 +9,9 @@ public sealed class ServerMapSettingsSchemaTests [Fact] public async Task ServerMapSettings_persists_with_all_layers_on_by_default() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = new RustServer { @@ -39,9 +39,9 @@ public async Task ServerMapSettings_persists_with_all_layers_on_by_default() [Fact] public async Task RemovingServer_CascadeDeletesItsMapSettings() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = new RustServer { diff --git a/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs b/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs index 0a75eede..96e7ab71 100644 --- a/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Persistence.Chat; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Credentials; using RustPlusBot.Persistence.Servers; @@ -34,5 +35,6 @@ public void AddBotPersistence_RegistersDbContextFactoryAndScopedServices() Assert.Contains(services, d => d.ServiceType == typeof(IFcmRegistrationStore)); Assert.Contains(services, d => d.ServiceType == typeof(IConnectionStore)); Assert.Contains(services, d => d.ServiceType == typeof(ISwitchStore)); + Assert.Contains(services, d => d.ServiceType == typeof(IChatWebhookStore)); } } diff --git a/tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj b/tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj index ad5827ea..987e3f2a 100644 --- a/tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj +++ b/tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/tests/RustPlusBot.Persistence.Tests/Servers/RustServerWipeColumnsTests.cs b/tests/RustPlusBot.Persistence.Tests/Servers/RustServerWipeColumnsTests.cs index 4e4f9b0a..9c49afda 100644 --- a/tests/RustPlusBot.Persistence.Tests/Servers/RustServerWipeColumnsTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Servers/RustServerWipeColumnsTests.cs @@ -10,8 +10,8 @@ public sealed class RustServerWipeColumnsTests [Fact] public async Task Wipe_baseline_columns_round_trip() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; await using var __ = context; var server = new RustServer @@ -39,8 +39,8 @@ public async Task Wipe_baseline_columns_round_trip() [Fact] public async Task New_server_has_empty_baseline_and_guild_ping_defaults_false() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; await using var __ = context; var server = new RustServer diff --git a/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs b/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs index eb89023d..bf491c6c 100644 --- a/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs @@ -8,9 +8,9 @@ public sealed class ServerServiceTests [Fact] public async Task AddAsync_PersistsServerScopedToGuild() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var service = new ServerService(context); var server = await service.AddAsync(10UL, 99UL, "Main", "127.0.0.1", 28082); @@ -24,9 +24,9 @@ public async Task AddAsync_PersistsServerScopedToGuild() [Fact] public async Task ListAsync_DoesNotLeakAcrossGuilds() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var service = new ServerService(context); await service.AddAsync(10UL, 1UL, "A", "1.1.1.1", 1); @@ -40,9 +40,9 @@ public async Task ListAsync_DoesNotLeakAcrossGuilds() [Fact] public async Task RemoveAsync_OnlyRemovesWithinGuild() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var service = new ServerService(context); var server = await service.AddAsync(10UL, 1UL, "A", "1.1.1.1", 1); @@ -55,9 +55,9 @@ public async Task RemoveAsync_OnlyRemovesWithinGuild() [Fact] public async Task ResolveOrCreateByEndpoint_CreatesWhenNew() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var service = new ServerService(context); var (server, created) = await service.ResolveOrCreateByEndpointAsync(10UL, 99UL, "Main", "1.2.3.4", 28015); @@ -70,9 +70,9 @@ public async Task ResolveOrCreateByEndpoint_CreatesWhenNew() [Fact] public async Task ResolveOrCreateByEndpoint_ReturnsExistingForSameEndpoint() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var service = new ServerService(context); var (Server, Created) = await service.ResolveOrCreateByEndpointAsync(10UL, 1UL, "Main", "1.2.3.4", 28015); @@ -87,9 +87,9 @@ public async Task ResolveOrCreateByEndpoint_ReturnsExistingForSameEndpoint() [Fact] public async Task ResolveOrCreateByEndpoint_DifferentPortIsDistinct() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var service = new ServerService(context); await service.ResolveOrCreateByEndpointAsync(10UL, 1UL, "A", "1.2.3.4", 28015); @@ -101,9 +101,9 @@ public async Task ResolveOrCreateByEndpoint_DifferentPortIsDistinct() [Fact] public async Task GetByEndpoint_returns_existing_server() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var service = new ServerService(context); var server = new RustServer { @@ -121,9 +121,9 @@ public async Task GetByEndpoint_returns_existing_server() [Fact] public async Task GetByEndpoint_returns_null_for_unknown_endpoint() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var service = new ServerService(context); Assert.Null(await service.GetByEndpointAsync(10UL, "9.9.9.9", 28015)); @@ -132,9 +132,9 @@ public async Task GetByEndpoint_returns_null_for_unknown_endpoint() [Fact] public async Task GetByFacepunchServerId_returns_matching_server() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var service = new ServerService(context); var fp = Guid.NewGuid(); var server = new RustServer @@ -158,9 +158,9 @@ public async Task GetByFacepunchServerId_returns_matching_server() [Fact] public async Task SetFacepunchServerId_backfills_then_is_idempotent() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var service = new ServerService(context); var server = new RustServer { diff --git a/tests/RustPlusBot.Persistence.Tests/SqliteContextFixture.cs b/tests/RustPlusBot.Persistence.Tests/SqliteContextFixture.cs index 87747b2c..0d0d2789 100644 --- a/tests/RustPlusBot.Persistence.Tests/SqliteContextFixture.cs +++ b/tests/RustPlusBot.Persistence.Tests/SqliteContextFixture.cs @@ -1,24 +1,27 @@ -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; +using Persistord.Core.Interception; +using Persistord.Testing; namespace RustPlusBot.Persistence.Tests; -/// Creates a BotDbContext over a private in-memory SQLite connection kept open for the test. +/// Creates a BotDbContext over a private in-memory SQLite database that lives as long as the test. public static class SqliteContextFixture { - public static (BotDbContext Context, SqliteConnection Connection) Create() + /// + /// Builds the context and its database. The schema comes from the committed EF Core migrations + /// (Persistord's default TestSchema.Migrate, not EnsureCreated) so tests exercise the same + /// schema path the Host uses at startup and catch migration/model drift. + /// + /// + /// The clock the stamps ICreatedAt/IUpdatedAt rows from. + /// Defaults to the system clock; pass a to assert on timestamps. + /// + /// The context and the database backing it. Dispose both. + public static (BotDbContext Context, SqliteTestDatabase Database) Create(TimeProvider? timeProvider = null) { - var connection = new SqliteConnection("DataSource=:memory:"); - connection.Open(); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; - - var context = new BotDbContext(options); - // Apply the committed EF Core migrations (not EnsureCreated) so tests exercise the same - // schema path the Host uses at startup, catching migration/model drift. - context.Database.Migrate(); - return (context, connection); + var database = SqliteTestDatabase.Private(); + var context = database.CreateContext( + options => new BotDbContext(options), + new TimestampInterceptor(timeProvider ?? TimeProvider.System)); + return (context, database); } } diff --git a/tests/RustPlusBot.Persistence.Tests/StorageMonitors/SmartStorageMonitorSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/StorageMonitors/SmartStorageMonitorSchemaTests.cs index 72ed090f..d0d9f29a 100644 --- a/tests/RustPlusBot.Persistence.Tests/StorageMonitors/SmartStorageMonitorSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/StorageMonitors/SmartStorageMonitorSchemaTests.cs @@ -9,8 +9,8 @@ public sealed class SmartStorageMonitorSchemaTests [Fact] public async Task SmartStorageMonitor_round_trips_through_sqlite() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; await using var __ = context; var server = new RustServer @@ -27,7 +27,7 @@ public async Task SmartStorageMonitor_round_trips_through_sqlite() EntityId = 777UL, Name = "Storage Monitor 777", PairedByUserId = 5UL, - CreatedUtc = DateTimeOffset.UnixEpoch, + CreatedAt = DateTimeOffset.UnixEpoch, }; context.Set().Add(entity); await context.SaveChangesAsync(); @@ -41,8 +41,8 @@ public async Task SmartStorageMonitor_round_trips_through_sqlite() [Fact] public async Task SmartStorageMonitor_cascades_when_server_removed() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; await using var __ = context; var server = new RustServer @@ -57,7 +57,7 @@ public async Task SmartStorageMonitor_cascades_when_server_removed() ServerId = server.Id, EntityId = 777UL, Name = "Storage Monitor 777", - CreatedUtc = DateTimeOffset.UnixEpoch, + CreatedAt = DateTimeOffset.UnixEpoch, }); await context.SaveChangesAsync(); @@ -70,8 +70,8 @@ public async Task SmartStorageMonitor_cascades_when_server_removed() [Fact] public async Task SmartStorageMonitor_unique_index_rejects_duplicate_entity() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; await using var __ = context; var server = new RustServer @@ -86,7 +86,7 @@ public async Task SmartStorageMonitor_unique_index_rejects_duplicate_entity() ServerId = server.Id, EntityId = 777UL, Name = "A", - CreatedUtc = DateTimeOffset.UnixEpoch, + CreatedAt = DateTimeOffset.UnixEpoch, }); context.Set().Add(new SmartStorageMonitor { @@ -94,7 +94,7 @@ public async Task SmartStorageMonitor_unique_index_rejects_duplicate_entity() ServerId = server.Id, EntityId = 777UL, Name = "B", - CreatedUtc = DateTimeOffset.UnixEpoch, + CreatedAt = DateTimeOffset.UnixEpoch, }); await Assert.ThrowsAsync(() => context.SaveChangesAsync()); diff --git a/tests/RustPlusBot.Persistence.Tests/StorageMonitors/StorageMonitorStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/StorageMonitors/StorageMonitorStoreTests.cs index f0ea5a3d..46a147b6 100644 --- a/tests/RustPlusBot.Persistence.Tests/StorageMonitors/StorageMonitorStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/StorageMonitors/StorageMonitorStoreTests.cs @@ -1,7 +1,5 @@ -using Microsoft.Data.Sqlite; -using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Persistence.StorageMonitors; @@ -9,12 +7,10 @@ namespace RustPlusBot.Persistence.Tests.StorageMonitors; public sealed class StorageMonitorStoreTests { - private static (StorageMonitorStore Store, BotDbContext Context, SqliteConnection Conn) Create() + private static (StorageMonitorStore Store, BotDbContext Context, SqliteTestDatabase Db) Create() { - var (context, connection) = SqliteContextFixture.Create(); - var clock = Substitute.For(); - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - return (new StorageMonitorStore(context, clock), context, connection); + var (context, database) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); + return (new StorageMonitorStore(context), context, database); } private static async Task SeedServerAsync(BotDbContext context, string ip = "1.1.1.1", string name = "S") @@ -43,7 +39,7 @@ public async Task Add_then_Get_round_trips() Assert.Equal(added.Id, loaded.Id); Assert.Equal("Box", loaded.Name); Assert.Equal(5UL, loaded.PairedByUserId); - Assert.Equal(DateTimeOffset.UnixEpoch, loaded.CreatedUtc); + Assert.Equal(DateTimeOffset.UnixEpoch, loaded.CreatedAt); } [Fact] @@ -79,7 +75,7 @@ public async Task Exists_reflects_presence() } [Fact] - public async Task ListByServer_returns_only_that_server_ordered_by_CreatedUtc() + public async Task ListByServer_returns_only_that_server_ordered_by_CreatedAt() { var (store, context, conn) = Create(); await using var _ = conn; diff --git a/tests/RustPlusBot.Persistence.Tests/Switches/SmartSwitchSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Switches/SmartSwitchSchemaTests.cs index e11d0da6..96b40c71 100644 --- a/tests/RustPlusBot.Persistence.Tests/Switches/SmartSwitchSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Switches/SmartSwitchSchemaTests.cs @@ -9,8 +9,8 @@ public sealed class SmartSwitchSchemaTests [Fact] public async Task SmartSwitch_round_trips_through_sqlite() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; await using var __ = context; var server = new RustServer @@ -28,7 +28,7 @@ public async Task SmartSwitch_round_trips_through_sqlite() Name = "Switch 42", PairedByUserId = 7UL, LastIsActive = true, - CreatedUtc = DateTimeOffset.UnixEpoch, + CreatedAt = DateTimeOffset.UnixEpoch, }; context.Set().Add(entity); await context.SaveChangesAsync(); @@ -43,8 +43,8 @@ public async Task SmartSwitch_round_trips_through_sqlite() [Fact] public async Task SmartSwitch_cascades_when_server_removed() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; await using var __ = context; var server = new RustServer @@ -59,7 +59,7 @@ public async Task SmartSwitch_cascades_when_server_removed() ServerId = server.Id, EntityId = 42UL, Name = "Switch 42", - CreatedUtc = DateTimeOffset.UnixEpoch, + CreatedAt = DateTimeOffset.UnixEpoch, }); await context.SaveChangesAsync(); @@ -72,8 +72,8 @@ public async Task SmartSwitch_cascades_when_server_removed() [Fact] public async Task SmartSwitch_unique_index_rejects_duplicate_entity() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; await using var __ = context; var server = new RustServer @@ -88,7 +88,7 @@ public async Task SmartSwitch_unique_index_rejects_duplicate_entity() ServerId = server.Id, EntityId = 42UL, Name = "A", - CreatedUtc = DateTimeOffset.UnixEpoch, + CreatedAt = DateTimeOffset.UnixEpoch, }); context.Set().Add(new SmartSwitch { @@ -96,7 +96,7 @@ public async Task SmartSwitch_unique_index_rejects_duplicate_entity() ServerId = server.Id, EntityId = 42UL, Name = "B", - CreatedUtc = DateTimeOffset.UnixEpoch, + CreatedAt = DateTimeOffset.UnixEpoch, }); await Assert.ThrowsAsync(() => context.SaveChangesAsync()); diff --git a/tests/RustPlusBot.Persistence.Tests/Switches/SwitchStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Switches/SwitchStoreTests.cs index d1ea3616..391081c8 100644 --- a/tests/RustPlusBot.Persistence.Tests/Switches/SwitchStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Switches/SwitchStoreTests.cs @@ -1,7 +1,5 @@ -using Microsoft.Data.Sqlite; -using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Persistence.Switches; @@ -9,12 +7,10 @@ namespace RustPlusBot.Persistence.Tests.Switches; public sealed class SwitchStoreTests { - private static (SwitchStore Store, BotDbContext Context, SqliteConnection Conn) Create() + private static (SwitchStore Store, BotDbContext Context, SqliteTestDatabase Db) Create() { - var (context, connection) = SqliteContextFixture.Create(); - var clock = Substitute.For(); - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - return (new SwitchStore(context, clock), context, connection); + var (context, database) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); + return (new SwitchStore(context), context, database); } private static async Task SeedServerAsync(BotDbContext context, string ip = "1.1.1.1", string name = "S") @@ -44,7 +40,7 @@ public async Task Add_then_Get_round_trips_and_defaults_state_off() Assert.Equal("Switch 42", loaded.Name); Assert.Equal(7UL, loaded.PairedByUserId); Assert.False(loaded.LastIsActive); - Assert.Equal(DateTimeOffset.UnixEpoch, loaded.CreatedUtc); + Assert.Equal(DateTimeOffset.UnixEpoch, loaded.CreatedAt); } [Fact] diff --git a/tests/RustPlusBot.Persistence.Tests/VendingStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/VendingStoreTests.cs index 28c44cf4..4f6650da 100644 --- a/tests/RustPlusBot.Persistence.Tests/VendingStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/VendingStoreTests.cs @@ -1,7 +1,5 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using NSubstitute; -using RustPlusBot.Abstractions.Time; +using Persistord.Testing; using RustPlusBot.Abstractions.Vending; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Vending; @@ -20,12 +18,12 @@ public sealed class VendingStoreTests private static readonly DateTimeOffset Later = DateTimeOffset.UnixEpoch.AddHours(1); - private static (VendingStore Store, BotDbContext Context, SqliteConnection Conn, IClock Clock) Create() + private static (VendingStore Store, BotDbContext Context, SqliteTestDatabase Db, FixedTimeProvider Time) + Create() { - var (context, connection) = SqliteContextFixture.Create(); - var clock = Substitute.For(); - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - return (new VendingStore(context, clock), context, connection, clock); + var time = new FixedTimeProvider(DateTimeOffset.UnixEpoch); + var (context, database) = SqliteContextFixture.Create(time); + return (new VendingStore(context, time), context, database, time); } private static async Task SeedServerAsync(BotDbContext context, ulong guildId = 10UL, int port = 28015) @@ -71,15 +69,16 @@ public async Task AddGrid_ConcurrentDuplicateLandingBetweenCheckAndSave_ReturnsR // by re-querying rather than letting the exception escape. context.SavingChanges += (_, _) => { - var rivalOptions = new DbContextOptionsBuilder().UseSqlite(conn).Options; - using var rival = new BotDbContext(rivalOptions); + // Options() reuses the database's held-open connection object, so the rival writes into + // the same private in-memory database rather than a fresh empty one. + using var rival = new BotDbContext(conn.Options()); rival.VendingGridTracks.Add(new VendingGridTrack { GuildId = 10UL, ServerId = serverId, Grid = "D7", RegisteredBySteamId = 2UL, - CreatedUtc = DateTimeOffset.UnixEpoch, + CreatedAt = DateTimeOffset.UnixEpoch, }); rival.SaveChanges(); }; @@ -320,7 +319,7 @@ public async Task UpsertNotification_NothingChanged_WritesNothingAtAll() // The relay reconciles every five seconds and re-upserts every live notice. Without the // unchanged-guard that is a SaveChanges per notice per poll, forever, and PostedUtc would be // rewritten each time so the column permanently read "just now". - var (store, context, conn, clock) = Create(); + var (store, context, conn, time) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); @@ -328,7 +327,7 @@ public async Task UpsertNotification_NothingChanged_WritesNothingAtAll() var saves = 0; context.SavingChanges += (_, _) => saves++; - clock.UtcNow.Returns(Later); + time.Now = Later; await store.UpsertNotificationAsync(10UL, serverId, Pipe, 555UL, 1, 10); @@ -342,13 +341,13 @@ public async Task UpsertNotification_NewMessageId_MovesPostedUtc() { // A different id means the message was genuinely reposted, which is exactly what PostedUtc // is supposed to record. - var (store, context, conn, clock) = Create(); + var (store, context, conn, time) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); await store.UpsertNotificationAsync(10UL, serverId, Pipe, 555UL, 1, 10); - clock.UtcNow.Returns(Later); + time.Now = Later; await store.UpsertNotificationAsync(10UL, serverId, Pipe, 556UL, 1, 10); var row = Assert.Single(await store.ListNotificationsAsync(10UL, serverId)); @@ -361,13 +360,13 @@ public async Task UpsertNotification_SameMessageRepriced_UpdatesTheReferenceButN { // The same message edited in place was not reposted, so its "posted" time must not move — // an unconditional rewrite would make every live notice read as brand new on every poll. - var (store, context, conn, clock) = Create(); + var (store, context, conn, time) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); await store.UpsertNotificationAsync(10UL, serverId, Pipe, 555UL, 1, 10); - clock.UtcNow.Returns(Later); + time.Now = Later; await store.UpsertNotificationAsync(10UL, serverId, Pipe, 555UL, 2, 18); var row = Assert.Single(await store.ListNotificationsAsync(10UL, serverId)); @@ -434,7 +433,7 @@ public async Task UpsertStockNotification_FirstCall_StoresTheMessageAndTheTimeIt [Fact] public async Task UpsertStockNotification_NothingChanged_WritesNothingAtAll() { - var (store, context, conn, clock) = Create(); + var (store, context, conn, time) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); @@ -442,7 +441,7 @@ public async Task UpsertStockNotification_NothingChanged_WritesNothingAtAll() var saves = 0; context.SavingChanges += (_, _) => saves++; - clock.UtcNow.Returns(Later); + time.Now = Later; await store.UpsertStockNotificationAsync(10UL, serverId, 1UL, 777UL, PipeDry); @@ -454,13 +453,13 @@ public async Task UpsertStockNotification_NothingChanged_WritesNothingAtAll() [Fact] public async Task UpsertStockNotification_NewMessageId_MovesPostedUtc() { - var (store, context, conn, clock) = Create(); + var (store, context, conn, time) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); await store.UpsertStockNotificationAsync(10UL, serverId, 1UL, 777UL, PipeDry); - clock.UtcNow.Returns(Later); + time.Now = Later; await store.UpsertStockNotificationAsync(10UL, serverId, 1UL, 778UL, PipeDry); var row = Assert.Single(await store.ListStockNotificationsAsync(10UL, serverId)); @@ -471,13 +470,13 @@ public async Task UpsertStockNotification_NewMessageId_MovesPostedUtc() [Fact] public async Task UpsertStockNotification_SameMessageNewSignature_UpdatesTheSignatureButNotPostedUtc() { - var (store, context, conn, clock) = Create(); + var (store, context, conn, time) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); await store.UpsertStockNotificationAsync(10UL, serverId, 1UL, 777UL, PipeDry); - clock.UtcNow.Returns(Later); + time.Now = Later; await store.UpsertStockNotificationAsync(10UL, serverId, 1UL, 777UL, PipeAndClothDry); var row = Assert.Single(await store.ListStockNotificationsAsync(10UL, serverId)); diff --git a/tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs index 3d7c787c..eee1468d 100644 --- a/tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs @@ -1,4 +1,4 @@ -using Microsoft.Data.Sqlite; +using Persistord.Testing; using RustPlusBot.Domain.Servers; using RustPlusBot.Persistence.Wipes; @@ -7,10 +7,10 @@ namespace RustPlusBot.Persistence.Tests.Wipes; /// Unit tests for . public sealed class WipeBaselineStoreTests { - private static (WipeBaselineStore Store, BotDbContext Context, SqliteConnection Conn) Create() + private static (WipeBaselineStore Store, BotDbContext Context, SqliteTestDatabase Db) Create() { - var (context, connection) = SqliteContextFixture.Create(); - return (new WipeBaselineStore(context), context, connection); + var (context, database) = SqliteContextFixture.Create(); + return (new WipeBaselineStore(context), context, database); } private static async Task SeedServerAsync(BotDbContext context) diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/ProvisioningSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/ProvisioningSchemaTests.cs index 4f0fa0b8..3ccf1578 100644 --- a/tests/RustPlusBot.Persistence.Tests/Workspace/ProvisioningSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/ProvisioningSchemaTests.cs @@ -9,9 +9,9 @@ public sealed class ProvisioningSchemaTests [Fact] public async Task RemovingServer_CascadeDeletesItsProvisioningRows() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; var server = new RustServer { @@ -33,9 +33,9 @@ public async Task RemovingServer_CascadeDeletesItsProvisioningRows() [Fact] public async Task GlobalCategory_HasNullServerId_AndPersists() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; + var (context, database) = SqliteContextFixture.Create(); + await using var _ = database; + await using var __ = context; context.ProvisionedCategories.Add(new ProvisionedCategory { diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs index b4b9f462..0d3aacef 100644 --- a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs @@ -1,4 +1,3 @@ -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; using RustPlusBot.Persistence.Workspace; @@ -9,10 +8,10 @@ public sealed class WorkspaceStoreByKeyTests { private static WorkspaceStore NewStore(out BotDbContext context, out IDisposable cleanup) { - var (ctx, connection) = SqliteContextFixture.Create(); + var (ctx, database) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); context = ctx; - cleanup = connection; - return new WorkspaceStore(ctx, new FixedClock(DateTimeOffset.UnixEpoch)); + cleanup = database; + return new WorkspaceStore(ctx); } [Fact] @@ -79,9 +78,4 @@ public async Task GetChannelsByKeyAsync_returns_empty_when_no_match() Assert.Empty(rows); } - - private sealed class FixedClock(DateTimeOffset now) : IClock - { - public DateTimeOffset UtcNow { get; } = now; - } } diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs index 0d1f0a4d..82f44c88 100644 --- a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs @@ -1,4 +1,3 @@ -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Workspace; using RustPlusBot.Persistence.Workspace; @@ -8,10 +7,10 @@ public sealed class WorkspaceStoreTests { private static WorkspaceStore NewStore(out BotDbContext context, out IDisposable cleanup) { - var (ctx, connection) = SqliteContextFixture.Create(); + var (ctx, database) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); context = ctx; - cleanup = connection; - return new WorkspaceStore(ctx, new FixedClock(DateTimeOffset.UnixEpoch)); + cleanup = database; + return new WorkspaceStore(ctx); } [Fact] @@ -169,9 +168,4 @@ public async Task Deleting_an_unknown_channel_is_a_no_op() Assert.Empty(await store.GetChannelsAsync(1, null)); } - - private sealed class FixedClock(DateTimeOffset now) : IClock - { - public DateTimeOffset UtcNow { get; } = now; - } } diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs index d22239d9..c053dfbd 100644 --- a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs @@ -1,4 +1,3 @@ -using RustPlusBot.Abstractions.Time; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Persistence.Tests.Workspace; @@ -9,10 +8,10 @@ public sealed class WorkspaceStoreWipePingTests [Fact] public async Task Ping_defaults_false_without_settings_row() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = connection; + var (context, database) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); + await using var _ = database; await using var __ = context; - var store = new WorkspaceStore(context, new FixedClock(DateTimeOffset.UnixEpoch)); + var store = new WorkspaceStore(context); Assert.False(await store.GetPingEveryoneOnWipeAsync(10UL)); } @@ -20,10 +19,10 @@ public async Task Ping_defaults_false_without_settings_row() [Fact] public async Task Set_true_then_get_round_trips_and_upserts_row() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = connection; + var (context, database) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); + await using var _ = database; await using var __ = context; - var store = new WorkspaceStore(context, new FixedClock(DateTimeOffset.UnixEpoch)); + var store = new WorkspaceStore(context); await store.SetPingEveryoneOnWipeAsync(10UL, enabled: true); @@ -35,10 +34,10 @@ public async Task Set_true_then_get_round_trips_and_upserts_row() [Fact] public async Task Set_preserves_existing_culture() { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = connection; + var (context, database) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); + await using var _ = database; await using var __ = context; - var store = new WorkspaceStore(context, new FixedClock(DateTimeOffset.UnixEpoch)); + var store = new WorkspaceStore(context); await store.SetCultureAsync(10UL, "fr"); await store.SetPingEveryoneOnWipeAsync(10UL, enabled: true); @@ -46,9 +45,4 @@ public async Task Set_preserves_existing_culture() Assert.Equal("fr", await store.GetCultureAsync(10UL)); Assert.True(await store.GetPingEveryoneOnWipeAsync(10UL)); } - - private sealed class FixedClock(DateTimeOffset now) : IClock - { - public DateTimeOffset UtcNow { get; } = now; - } }