From 91195f7a12c114032086784c7744eec369123b6d Mon Sep 17 00:00:00 2001 From: = Date: Thu, 10 Sep 2026 16:40:18 +0200 Subject: [PATCH 1/6] chore: adopt Persistord 1.0.0-beta.3 and drop the tables nothing writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beta.3 moves the Discord skeleton graph out of DiscordDbContext and into DiscordGraphDbContext. The bot owns its Discord resources rather than mirroring them, so it stays on DiscordDbContext and the five mirror tables (Guilds/Channels/Users/Members/Roles) leave the model — they have never held a row. EventSubscriptions and PairedEntities go with them: production code only ever purged them, nothing ever wrote one. ClearAllTablesAsync replaces the hand-rolled factory reset, which needed a SQLite-only defer_foreign_keys pragma, raw DELETE statements and an identifier guard to satisfy the Sonar gate. SnowflakeKeyConvention makes GuildSettings' explicit ValueGeneratedNever redundant. Co-Authored-By: Claude Opus 5 (1M context) --- Directory.Packages.props | 4 +- .../Entities/PairedEntity.cs | 23 - .../Events/EventSubscription.cs | 17 - .../Teardown/GuildPurgeService.cs | 8 +- src/RustPlusBot.Persistence/BotDbContext.cs | 18 +- .../EventSubscriptionConfiguration.cs | 19 - .../GuildSettingsConfiguration.cs | 2 +- .../PairedEntityConfiguration.cs | 19 - .../Maintenance/DatabaseMaintenanceService.cs | 50 +- ...143851_DropMirrorAndDeadTables.Designer.cs | 878 ++++++++++++++++++ .../20260910143851_DropMirrorAndDeadTables.cs | 171 ++++ .../Migrations/BotDbContextModelSnapshot.cs | 167 ---- .../Teardown/GuildPurgeServiceTests.cs | 17 - 13 files changed, 1067 insertions(+), 326 deletions(-) delete mode 100644 src/RustPlusBot.Domain/Entities/PairedEntity.cs delete mode 100644 src/RustPlusBot.Domain/Events/EventSubscription.cs delete mode 100644 src/RustPlusBot.Persistence/Configurations/EventSubscriptionConfiguration.cs delete mode 100644 src/RustPlusBot.Persistence/Configurations/PairedEntityConfiguration.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.Designer.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.cs 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/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.Features.Workspace/Teardown/GuildPurgeService.cs b/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs index d4dd2761..aa0a5600 100644 --- a/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs +++ b/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs @@ -44,12 +44,8 @@ public async Task PurgeGuildAsync(ulong guildId, CancellationToken cancellationT 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); + // 3) Delete guild-keyed rows that have no cascade FK to RustServer (guild settings, + // FCM registrations). await context.GuildSettings.Where(g => g.GuildId == guildId) .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.FcmRegistrations.Where(f => f.GuildId == guildId) diff --git a/src/RustPlusBot.Persistence/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs index 51333237..5e57d9d4 100644 --- a/src/RustPlusBot.Persistence/BotDbContext.cs +++ b/src/RustPlusBot.Persistence/BotDbContext.cs @@ -6,8 +6,6 @@ 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 +18,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 +47,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 +56,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(); @@ -93,7 +87,7 @@ public sealed class BotDbContext(DbContextOptions options) : Disco 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 +102,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()) 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/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/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/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index 3d320fd5..a5c5c28d 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -17,111 +17,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); - modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b => - { - b.Property("Id") - .HasColumnType("INTEGER"); - - b.Property("GuildId") - .HasColumnType("INTEGER"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("ParentId") - .HasColumnType("INTEGER"); - - b.Property("Type") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("GuildId"); - - b.HasIndex("ParentId"); - - b.ToTable("Channels"); - }); - - modelBuilder.Entity("Persistord.Core.Entities.GuildEntity", b => - { - b.Property("Id") - .HasColumnType("INTEGER"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("OwnerId") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.ToTable("Guilds"); - }); - - modelBuilder.Entity("Persistord.Core.Entities.MemberEntity", b => - { - b.Property("GuildId") - .HasColumnType("INTEGER"); - - b.Property("UserId") - .HasColumnType("INTEGER"); - - b.Property("JoinedAt") - .HasColumnType("TEXT"); - - b.Property("Nickname") - .HasColumnType("TEXT"); - - b.HasKey("GuildId", "UserId"); - - b.ToTable("Members"); - }); - - modelBuilder.Entity("Persistord.Core.Entities.RoleEntity", b => - { - b.Property("Id") - .HasColumnType("INTEGER"); - - b.Property("Color") - .HasColumnType("INTEGER"); - - b.Property("GuildId") - .HasColumnType("INTEGER"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Permissions") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("GuildId"); - - b.ToTable("Roles"); - }); - - modelBuilder.Entity("Persistord.Core.Entities.UserEntity", b => - { - b.Property("Id") - .HasColumnType("INTEGER"); - - b.Property("GlobalName") - .HasColumnType("TEXT"); - - b.Property("Username") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("Users"); - }); - modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b => { b.Property("Id") @@ -378,60 +273,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") @@ -888,14 +729,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/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs index 992b7a57..c8fb43c7 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs @@ -4,8 +4,6 @@ using RustPlusBot.Abstractions.Connections; 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; @@ -54,18 +52,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" @@ -103,14 +89,11 @@ 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()); // 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()); } From 97f6f2e062adba7c383f23e45bcac035041a13ab Mon Sep 17 00:00:00 2001 From: = Date: Thu, 10 Sep 2026 16:55:19 +0200 Subject: [PATCH 2/6] refactor: let Persistord own upserts, timestamps and the test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every store that wrote a row was hand-rolling the same three things: read-then-insert-or-update, a catch(DbUpdateException) that detaches, re-reads the winner and re-applies the mutation, and a manual clock stamp. Persistord's UpsertAsync does all three, so fifteen call sites collapse and six identical race-recovery blocks disappear with them. ICreatedAt/IUpdatedAt plus a TimestampInterceptor registered on the context factory replace the manual stamps. That needed one naming decision: the interfaces name the columns CreatedAt/UpdatedAt, so the five entities that spelled them CreatedUtc/UpdatedUtc are renamed to match (a column rename, no data touched). PostedUtc and LastSeenUtc stay hand-written — they record when something happened in Discord or in game, not when the row was last written, and VendingStore deliberately only moves PostedUtc when a message is reposted. ClanStore and VendingStore now take TimeProvider rather than IClock, so the persistence layer reads one clock. Two shapes deliberately keep their own logic: ConnectionStore's insert guard (it must swallow a vanished-parent FK violation and report "no change", which is not a natural-key race) and ServerService.ResolveOrCreateByEndpointAsync (its caller needs "was it created?", which UpsertResult.Changed does not answer — Changed is true for any pending write in the context). Tests move onto Persistord.Testing: SqliteTestDatabase replaces two copies of the in-memory fixture and the shared-cache boilerplate inlined across twenty files, and a FixedTimeProvider drives the interceptor where a test asserts on a timestamp. Co-Authored-By: Claude Opus 5 (1M context) --- src/RustPlusBot.Domain/Alarms/SmartAlarm.cs | 7 +- .../Clans/ClanPlayerName.cs | 8 +- .../Connections/ConnectionState.cs | 6 +- .../Credentials/FcmRegistration.cs | 6 +- .../Devices/PairedDeviceEntity.cs | 7 +- .../RustPlusBot.Domain.csproj | 7 + .../Vending/VendingGridTrack.cs | 8 +- .../Vending/VendingListingTrack.cs | 8 +- .../Workspace/ProvisionedCategory.cs | 6 +- .../Workspace/ProvisionedChannel.cs | 6 +- .../Workspace/ProvisionedMessage.cs | 8 +- .../Alarms/AlarmStore.cs | 58 +- .../Clans/ClanStore.cs | 66 +- .../Commands/MuteStore.cs | 29 +- .../Connections/ConnectionStore.cs | 6 +- .../Credentials/CredentialStore.cs | 47 +- .../Credentials/FcmRegistrationStore.cs | 60 +- .../Devices/PairedDeviceStore.cs | 58 +- .../Map/MapSettingsStore.cs | 65 +- ...260910145042_StampedTimestamps.Designer.cs | 878 ++++++++++++++++++ .../20260910145042_StampedTimestamps.cs | 78 ++ .../Migrations/BotDbContextModelSnapshot.cs | 12 +- .../PersistenceServiceCollectionExtensions.cs | 12 +- .../StorageMonitors/StorageMonitorStore.cs | 6 +- .../Switches/SwitchStore.cs | 6 +- .../Vending/VendingStore.cs | 110 +-- .../Workspace/WorkspaceStore.cs | 120 +-- .../AlarmPrimingTests.cs | 17 +- .../AlarmSweepTests.cs | 17 +- .../ClanSupervisorTests.cs | 15 +- .../ConnectionSupervisorTests.cs | 15 +- .../MapImageQueryTests.cs | 17 +- ...tPlusBot.Features.Connections.Tests.csproj | 1 + .../ServerQueryTests.cs | 17 +- .../StorageMonitorPrimingTests.cs | 17 +- .../StorageSweepTests.cs | 17 +- .../SwitchPrimingTests.cs | 17 +- .../SwitchQueryTests.cs | 17 +- .../TeamChatSenderTests.cs | 17 +- .../PairingSupervisorTests.cs | 11 +- .../RustPlusBot.Features.Pairing.Tests.csproj | 1 + .../ServerPairingCoordinatorTests.cs | 24 +- .../TestDb.cs | 17 +- .../Locating/AlarmChannelLocatorTests.cs | 17 +- .../Locating/CachingChannelLocatorTests.cs | 17 +- .../Locating/EventChannelLocatorTests.cs | 17 +- .../Locating/MapChannelLocatorTests.cs | 17 +- .../PlayerEventChannelLocatorTests.cs | 17 +- .../Locating/SetupChannelLocatorTests.cs | 17 +- .../StorageMonitorChannelLocatorTests.cs | 17 +- .../Locating/SwitchChannelLocatorTests.cs | 17 +- .../Locating/TeamChatChannelLocatorTests.cs | 17 +- ...ustPlusBot.Features.Workspace.Tests.csproj | 1 + .../Teardown/GuildPurgeServiceTests.cs | 22 +- .../Teardown/ServerPurgeServiceTests.cs | 14 +- .../Alarms/AlarmStoreTests.cs | 44 +- .../Alarms/SmartAlarmSchemaTests.cs | 6 +- .../ClanStoreTests.cs | 35 +- .../Commands/MuteStoreTests.cs | 4 +- .../Connections/ConnectionStoreTests.cs | 53 +- .../Credentials/FcmRegistrationStoreTests.cs | 32 +- .../Devices/PairedDeviceStoreTests.cs | 18 +- .../FixedTimeProvider.cs | 12 + .../RustPlusBot.Persistence.Tests.csproj | 1 + .../SqliteContextFixture.cs | 35 +- .../SmartStorageMonitorSchemaTests.cs | 8 +- .../StorageMonitorStoreTests.cs | 16 +- .../Switches/SmartSwitchSchemaTests.cs | 8 +- .../Switches/SwitchStoreTests.cs | 14 +- .../VendingStoreTests.cs | 45 +- .../Wipes/WipeBaselineStoreTests.cs | 4 +- .../Workspace/WorkspaceStoreByKeyTests.cs | 10 +- .../Workspace/WorkspaceStoreTests.cs | 10 +- .../Workspace/WorkspaceStoreWipePingTests.cs | 18 +- 74 files changed, 1597 insertions(+), 891 deletions(-) create mode 100644 src/RustPlusBot.Persistence/Migrations/20260910145042_StampedTimestamps.Designer.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260910145042_StampedTimestamps.cs create mode 100644 tests/RustPlusBot.Persistence.Tests/FixedTimeProvider.cs diff --git a/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs index 47290f85..498f651c 100644 --- a/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs +++ b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs @@ -1,9 +1,10 @@ +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 : ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); @@ -26,8 +27,8 @@ 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 the alarm was accepted (UTC). Stamped by Persistord's TimestampInterceptor. + public DateTimeOffset CreatedAt { get; set; } /// When true, a trigger going active pings @everyone in #alarms. public bool PingEveryone { get; set; } diff --git a/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs b/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs index 40b9db9c..e1d309e8 100644 --- a/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs +++ b/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs @@ -1,10 +1,12 @@ +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 : IUpdatedAt { /// The owning guild snowflake. public ulong GuildId { get; set; } @@ -18,6 +20,6 @@ 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; } + /// When the name was last observed (UTC). Stamped by Persistord's TimestampInterceptor. + public DateTimeOffset UpdatedAt { get; set; } } diff --git a/src/RustPlusBot.Domain/Connections/ConnectionState.cs b/src/RustPlusBot.Domain/Connections/ConnectionState.cs index 80205042..6435b738 100644 --- a/src/RustPlusBot.Domain/Connections/ConnectionState.cs +++ b/src/RustPlusBot.Domain/Connections/ConnectionState.cs @@ -1,7 +1,9 @@ +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 : IUpdatedAt { /// The server this state belongs to (primary key, one row per server). public Guid RustServerId { get; set; } @@ -18,6 +20,6 @@ public sealed class ConnectionState /// Last heartbeat player count, or null if unknown. public int? PlayerCount { get; set; } - /// When the state was last updated (UTC). + /// 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..ca1e21e1 100644 --- a/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs +++ b/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs @@ -1,10 +1,12 @@ +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 : IUpdatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); @@ -21,6 +23,6 @@ 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). + /// When the registration was last written (UTC). Stamped by Persistord's TimestampInterceptor. public DateTimeOffset UpdatedAt { get; set; } } diff --git a/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs b/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs index 3baa2b73..c2d20a04 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,7 +15,7 @@ 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 : ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); @@ -37,8 +38,8 @@ 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; } + /// When the pairing was accepted (UTC). Stamped by Persistord's TimestampInterceptor. + public DateTimeOffset CreatedAt { get; set; } /// Per-device reachability; defaults to Reachable. Orthogonal to whole-server connection status. public DeviceReachability Reachability { get; set; } = DeviceReachability.Reachable; 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/Vending/VendingGridTrack.cs b/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs index 503a3768..1658f1b1 100644 --- a/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs +++ b/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs @@ -1,7 +1,9 @@ +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 : ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); @@ -18,6 +20,6 @@ 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; } } diff --git a/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs b/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs index 8f9c7a4a..bb3c9da5 100644 --- a/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs +++ b/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs @@ -1,7 +1,9 @@ +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 : ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); @@ -33,6 +35,6 @@ 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; } } diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs index 48937caf..6db71704 100644 --- a/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs @@ -1,7 +1,9 @@ +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 : ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); @@ -15,6 +17,6 @@ public sealed class ProvisionedCategory /// 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; } } diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs index 853a1890..29efd801 100644 --- a/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs @@ -1,7 +1,9 @@ +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 : ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); @@ -18,6 +20,6 @@ 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; } } diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs index 5d1a2dd9..11968a89 100644 --- a/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs @@ -1,7 +1,9 @@ +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 : ICreatedAt, IUpdatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); @@ -21,9 +23,9 @@ 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). + /// When the record was last written (UTC). Stamped by Persistord's TimestampInterceptor. public DateTimeOffset UpdatedAt { get; set; } } 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/Clans/ClanStore.cs b/src/RustPlusBot.Persistence/Clans/ClanStore.cs index b32c9500..7235a33a 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,19 +52,19 @@ 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); - } - + private static void Apply(ClanState row, ulong guildId, ClanSnapshot snapshot, DateTimeOffset seenAt) + { row.GuildId = guildId; row.ClanId = snapshot.ClanId; row.Name = snapshot.Name; @@ -80,9 +80,7 @@ public async Task SaveAsync( 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); + row.LastSeenUtc = seenAt; } /// @@ -143,30 +141,20 @@ 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); - } - - row.GuildId = guildId; - row.Name = name; - row.UpdatedUtc = clock.UtcNow; - - await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } } 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/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/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/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/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index a5c5c28d..6bddda49 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -23,7 +23,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("CreatedUtc") + b.Property("CreatedAt") .HasColumnType("TEXT"); b.Property("EntityId") @@ -89,7 +89,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(64) .HasColumnType("TEXT"); - b.Property("UpdatedUtc") + b.Property("UpdatedAt") .HasColumnType("TEXT"); b.HasKey("ServerId", "SteamId"); @@ -383,7 +383,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("CreatedUtc") + b.Property("CreatedAt") .HasColumnType("TEXT"); b.Property("EntityId") @@ -427,7 +427,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("CreatedUtc") + b.Property("CreatedAt") .HasColumnType("TEXT"); b.Property("EntityId") @@ -474,7 +474,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("CreatedUtc") + b.Property("CreatedAt") .HasColumnType("TEXT"); b.Property("Grid") @@ -510,7 +510,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CostPerOrder") .HasColumnType("INTEGER"); - b.Property("CreatedUtc") + b.Property("CreatedAt") .HasColumnType("TEXT"); b.Property("CurrencyId") diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs index 026a41a6..4cd5c07e 100644 --- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ 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.Clans; @@ -28,7 +30,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 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..100ebd1e 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,22 @@ 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 +99,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 +191,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 +210,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 +280,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 +295,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.Connections.Tests/AlarmPrimingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs index 601c1ec1..0bc517f2 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; @@ -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..18a911ce 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs @@ -1,8 +1,8 @@ using System.Collections.Concurrent; -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; @@ -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..4f5625fe 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Connections; @@ -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..b329142f 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs @@ -1,10 +1,10 @@ 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 Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Connections; @@ -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..bef5f03a 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; @@ -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..97d763c0 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; @@ -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..c742038a 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorPrimingTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorPrimingTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; @@ -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..a6e2364a 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; @@ -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..b9eaea08 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/SwitchPrimingTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/SwitchPrimingTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; @@ -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..7a72f4f9 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; @@ -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..51a2c8f5 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Credentials; @@ -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/PairingSupervisorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs index 6125255f..ff466c69 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.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.Options; using NSubstitute; using NSubstitute.ExceptionExtensions; +using Persistord.Testing; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; @@ -37,12 +37,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..a75c1392 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs @@ -69,7 +69,7 @@ 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 h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None); @@ -86,7 +86,7 @@ public async Task Detected_again_while_pending_refreshes_token_and_reensures_wit { var h = Create(); await using var _ = h.Context; - await using var __ = h.Connection; + await using var __ = h.Database; await h.Coordinator.HandleDetectedAsync(10UL, 1UL, ServerPairing(steam: 1UL), CancellationToken.None); await h.Coordinator.HandleDetectedAsync(10UL, 2UL, ServerPairing(steam: 2UL), CancellationToken.None); @@ -110,7 +110,7 @@ public async Task Repeat_detection_reensures_prompt_so_a_deleted_message_self_he { var h = Create(); await using var _ = h.Context; - await using var __ = h.Connection; + await using var __ = h.Database; // First detection posts the prompt as message 900 (the Create() default). await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None); @@ -138,7 +138,7 @@ public async Task Detected_without_setup_channel_notifies_owner_and_drops() { var h = Create(channelId: null); await using var _ = h.Context; - await using var __ = h.Connection; + await using var __ = h.Database; await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None); @@ -154,7 +154,7 @@ 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 h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None); var outcome = await h.Coordinator.TryAcceptAsync(10UL, "1.2.3.4", 28015, CancellationToken.None); @@ -179,7 +179,7 @@ public async Task Accept_when_server_already_exists_upserts_credential_without_e { var h = Create(); await using var _ = h.Context; - await using var __ = h.Connection; + await using var __ = h.Database; await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None); // Another path created the same endpoint while the prompt sat unanswered. @@ -199,7 +199,7 @@ public async Task Accept_without_pending_returns_expired_and_persists_nothing() { var h = Create(); await using var _ = h.Context; - await using var __ = h.Connection; + await using var __ = h.Database; var outcome = await h.Coordinator.TryAcceptAsync(10UL, "1.2.3.4", 28015, CancellationToken.None); @@ -213,7 +213,7 @@ public async Task Concurrent_detections_for_same_endpoint_post_single_prompt() { var h = Create(); await using var _ = h.Context; - await using var __ = h.Connection; + await using var __ = h.Database; var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); h.Poster.EnsureAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) @@ -238,7 +238,7 @@ public async Task Detected_with_failed_prompt_post_drops_pending_and_repair_retr { var h = Create(); await using var _ = h.Context; - await using var __ = h.Connection; + await using var __ = h.Database; h.Poster.EnsureAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns((ulong?)null); @@ -265,7 +265,7 @@ 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 h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None); h.Locator.GetChannelIdAsync(Arg.Any(), Arg.Any()).Returns((ulong?)null); @@ -291,7 +291,7 @@ 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 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..1bd9a07b 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/AlarmChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/AlarmChannelLocatorTests.cs @@ -1,6 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; @@ -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..02a1d657 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/CachingChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/CachingChannelLocatorTests.cs @@ -1,6 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; @@ -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..232304bd 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/EventChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/EventChannelLocatorTests.cs @@ -1,6 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; @@ -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..ceb4f26c 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/MapChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/MapChannelLocatorTests.cs @@ -1,6 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; @@ -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..2218d047 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/PlayerEventChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/PlayerEventChannelLocatorTests.cs @@ -1,6 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; @@ -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..ee6f8f9f 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/SetupChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/SetupChannelLocatorTests.cs @@ -1,6 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; @@ -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..42ee43f2 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/StorageMonitorChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/StorageMonitorChannelLocatorTests.cs @@ -1,6 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; @@ -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..9731fcef 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/SwitchChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/SwitchChannelLocatorTests.cs @@ -1,6 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; @@ -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..c66ffa67 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs @@ -1,6 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Persistord.Testing; using NSubstitute; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; @@ -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 c8fb43c7..69934663 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs @@ -1,6 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; @@ -19,21 +19,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 { @@ -106,10 +96,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..93970722 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/ServerPurgeServiceTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/ServerPurgeServiceTests.cs @@ -1,6 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; @@ -99,12 +99,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 +122,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..1f4786d6 100644 --- a/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs @@ -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(); @@ -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/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..22a00aa0 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,7 +6,7 @@ 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); 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/FcmRegistrationStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs index 8b7bcf95..cda72fad 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; @@ -16,20 +15,15 @@ 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; - } + private static readonly DateTimeOffset Now = new(2026, 6, 15, 0, 0, 0, TimeSpan.Zero); [Fact] public async Task Upsert_StoresProtectedAndActive() { - var (context, connection) = SqliteContextFixture.Create(); + var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); await using var _ = context; await using var __ = connection; - var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + 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(); + var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); await using var _ = context; await using var __ = connection; - var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + 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(); + var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); await using var _ = context; await using var __ = connection; - var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + 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(); + var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); await using var _ = context; await using var __ = connection; - var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + 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(); + var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); await using var _ = context; await using var __ = connection; - var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + var store = new FcmRegistrationStore(context, PassThroughProtector()); await store.UpsertAsync(10UL, 99UL, "a"); 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/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/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..aa61bc99 100644 --- a/tests/RustPlusBot.Persistence.Tests/StorageMonitors/SmartStorageMonitorSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/StorageMonitors/SmartStorageMonitorSchemaTests.cs @@ -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(); @@ -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(); @@ -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..b815ce11 100644 --- a/tests/RustPlusBot.Persistence.Tests/Switches/SmartSwitchSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Switches/SmartSwitchSchemaTests.cs @@ -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(); @@ -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(); @@ -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..24cd73cd 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,7 +7,7 @@ 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); diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs index b4b9f462..c63b752f 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, connection) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); context = ctx; cleanup = connection; - return new WorkspaceStore(ctx, new FixedClock(DateTimeOffset.UnixEpoch)); + 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..46e89ac0 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, connection) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); context = ctx; cleanup = connection; - return new WorkspaceStore(ctx, new FixedClock(DateTimeOffset.UnixEpoch)); + 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..a4f75ff8 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(); + var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); await using var _ = connection; 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(); + var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); await using var _ = connection; 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(); + var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); await using var _ = connection; 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; - } } From 3d090782931fccabf74a084183426954cb87d28b Mon Sep 17 00:00:00 2001 From: = Date: Thu, 10 Sep 2026 16:58:50 +0200 Subject: [PATCH 3/6] refactor: make the guild the tenant root via IGuildScoped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every mapped entity carries a GuildId, so every one of them now declares IGuildScoped. That turns the guild purge from a hand-maintained list — four ExecuteDelete calls outside any transaction, plus a per-server RemoveAsync loop leaning on the RustServer cascade — into one PurgeGuildAsync: dependents before principals, in a single transaction, covering both the rows that cascaded off RustServer and the ones that never had a foreign key to it. A new guild-scoped table now joins the purge by declaring the interface instead of by someone remembering to add a line. Stopping the connection loops stays where it was, before any delete: a loop whose server row has vanished faults on the connection-state foreign key at its next status write and leaks its socket. The purge runs as SQL and does not touch the change tracker, so the tracker is cleared after it. ApplyGuildRoot is deliberately not used: the bot has no Guilds table and never writes one, and the cascading foreign key it wires would make a guild row a prerequisite for every insert. PurgeGuildAsync needs neither. GuildScopeConvention adds a GuildId index to the five per-server tables whose key leads with ServerId; the rest already lead with GuildId and are untouched. Co-Authored-By: Claude Opus 5 (1M context) --- src/RustPlusBot.Domain/Alarms/SmartAlarm.cs | 2 +- .../Clans/ClanPlayerName.cs | 2 +- src/RustPlusBot.Domain/Clans/ClanState.cs | 4 +- .../Commands/ServerCommandSettings.cs | 4 +- .../Connections/ConnectionState.cs | 2 +- .../Credentials/FcmRegistration.cs | 2 +- .../Credentials/PlayerCredential.cs | 4 +- .../Devices/PairedDeviceEntity.cs | 2 +- .../Guilds/GuildSettings.cs | 4 +- .../Map/ServerMapSettings.cs | 4 +- src/RustPlusBot.Domain/Servers/RustServer.cs | 4 +- .../Vending/VendingGridTrack.cs | 2 +- .../Vending/VendingListingTrack.cs | 2 +- .../Vending/VendingNotification.cs | 4 +- .../Vending/VendingStockNotification.cs | 4 +- .../Workspace/ProvisionedCategory.cs | 2 +- .../Workspace/ProvisionedChannel.cs | 2 +- .../Workspace/ProvisionedMessage.cs | 2 +- .../Teardown/GuildPurgeService.cs | 32 +- ...260910145600_GuildScopeIndexes.Designer.cs | 888 ++++++++++++++++++ .../20260910145600_GuildScopeIndexes.cs | 63 ++ .../Migrations/BotDbContextModelSnapshot.cs | 10 + .../Teardown/GuildPurgeServiceTests.cs | 20 + .../BotDbContextTests.cs | 41 + 24 files changed, 1073 insertions(+), 33 deletions(-) create mode 100644 src/RustPlusBot.Persistence/Migrations/20260910145600_GuildScopeIndexes.Designer.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260910145600_GuildScopeIndexes.cs diff --git a/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs index 498f651c..9bff5310 100644 --- a/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs +++ b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs @@ -4,7 +4,7 @@ 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 : ICreatedAt +public sealed class SmartAlarm : IGuildScoped, ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); diff --git a/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs b/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs index e1d309e8..bcedaab7 100644 --- a/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs +++ b/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs @@ -6,7 +6,7 @@ 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 : IUpdatedAt +public sealed class ClanPlayerName : IGuildScoped, IUpdatedAt { /// The owning guild snowflake. public ulong GuildId { get; set; } diff --git a/src/RustPlusBot.Domain/Clans/ClanState.cs b/src/RustPlusBot.Domain/Clans/ClanState.cs index e121b5c3..7cf97f22 100644 --- a/src/RustPlusBot.Domain/Clans/ClanState.cs +++ b/src/RustPlusBot.Domain/Clans/ClanState.cs @@ -1,10 +1,12 @@ +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; } diff --git a/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs b/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs index f58560af..424c22c4 100644 --- a/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs +++ b/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs @@ -1,7 +1,9 @@ +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; } diff --git a/src/RustPlusBot.Domain/Connections/ConnectionState.cs b/src/RustPlusBot.Domain/Connections/ConnectionState.cs index 6435b738..81d005d3 100644 --- a/src/RustPlusBot.Domain/Connections/ConnectionState.cs +++ b/src/RustPlusBot.Domain/Connections/ConnectionState.cs @@ -3,7 +3,7 @@ namespace RustPlusBot.Domain.Connections; /// Persisted last-known connection state per server, so the active identity and status survive restarts. -public sealed class ConnectionState : IUpdatedAt +public sealed class ConnectionState : IGuildScoped, IUpdatedAt { /// The server this state belongs to (primary key, one row per server). public Guid RustServerId { get; set; } diff --git a/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs b/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs index ca1e21e1..32358a1c 100644 --- a/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs +++ b/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs @@ -6,7 +6,7 @@ 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 : IUpdatedAt +public sealed class FcmRegistration : IGuildScoped, IUpdatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); diff --git a/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs b/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs index f2b34a6b..1404a1f9 100644 --- a/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs +++ b/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs @@ -1,10 +1,12 @@ +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(); diff --git a/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs b/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs index c2d20a04..b36a27d2 100644 --- a/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs +++ b/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs @@ -15,7 +15,7 @@ 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 : ICreatedAt +public abstract class PairedDeviceEntity : IGuildScoped, ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); diff --git a/src/RustPlusBot.Domain/Guilds/GuildSettings.cs b/src/RustPlusBot.Domain/Guilds/GuildSettings.cs index d69200cb..2823937f 100644 --- a/src/RustPlusBot.Domain/Guilds/GuildSettings.cs +++ b/src/RustPlusBot.Domain/Guilds/GuildSettings.cs @@ -1,7 +1,9 @@ +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; } diff --git a/src/RustPlusBot.Domain/Map/ServerMapSettings.cs b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs index ce16aa7d..851324d8 100644 --- a/src/RustPlusBot.Domain/Map/ServerMapSettings.cs +++ b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs @@ -1,9 +1,11 @@ using RustPlusBot.Abstractions.Connections; +using Persistord.Core.Abstractions; + 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; } diff --git a/src/RustPlusBot.Domain/Servers/RustServer.cs b/src/RustPlusBot.Domain/Servers/RustServer.cs index 197dcc8c..cbcb2ed3 100644 --- a/src/RustPlusBot.Domain/Servers/RustServer.cs +++ b/src/RustPlusBot.Domain/Servers/RustServer.cs @@ -1,7 +1,9 @@ +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(); diff --git a/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs b/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs index 1658f1b1..910cfae7 100644 --- a/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs +++ b/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs @@ -3,7 +3,7 @@ namespace RustPlusBot.Domain.Vending; /// A registered grid cell; every vending machine inside it counts as the team's own. -public sealed class VendingGridTrack : ICreatedAt +public sealed class VendingGridTrack : IGuildScoped, ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); diff --git a/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs b/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs index bb3c9da5..57e46934 100644 --- a/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs +++ b/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs @@ -3,7 +3,7 @@ namespace RustPlusBot.Domain.Vending; /// A listing the team sells, registered by hand rather than read off a machine. -public sealed class VendingListingTrack : ICreatedAt +public sealed class VendingListingTrack : IGuildScoped, ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); diff --git a/src/RustPlusBot.Domain/Vending/VendingNotification.cs b/src/RustPlusBot.Domain/Vending/VendingNotification.cs index 65dd8c57..697f7931 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,7 +7,7 @@ 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(); diff --git a/src/RustPlusBot.Domain/Vending/VendingStockNotification.cs b/src/RustPlusBot.Domain/Vending/VendingStockNotification.cs index 14dc3252..660b9b54 100644 --- a/src/RustPlusBot.Domain/Vending/VendingStockNotification.cs +++ b/src/RustPlusBot.Domain/Vending/VendingStockNotification.cs @@ -1,10 +1,12 @@ +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(); diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs index 6db71704..9afe7fe5 100644 --- a/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs @@ -3,7 +3,7 @@ namespace RustPlusBot.Domain.Workspace; /// A Discord category the bot has provisioned. One per scope (global or per-server). -public sealed class ProvisionedCategory : ICreatedAt +public sealed class ProvisionedCategory : IGuildScoped, ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs index 29efd801..fc9e5a08 100644 --- a/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs @@ -3,7 +3,7 @@ namespace RustPlusBot.Domain.Workspace; /// A Discord text channel the bot has provisioned, identified by its stable spec key. -public sealed class ProvisionedChannel : ICreatedAt +public sealed class ProvisionedChannel : IGuildScoped, ICreatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs index 11968a89..b57cb97d 100644 --- a/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs @@ -3,7 +3,7 @@ namespace RustPlusBot.Domain.Workspace; /// An anchored bot message, edited in place rather than re-posted. -public sealed class ProvisionedMessage : ICreatedAt, IUpdatedAt +public sealed class ProvisionedMessage : IGuildScoped, ICreatedAt, IUpdatedAt { /// Surrogate primary key. public Guid Id { get; set; } = Guid.NewGuid(); diff --git a/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs b/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs index aa0a5600..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,24 +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 (guild settings, - // FCM registrations). - 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/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/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index 6bddda49..56112a12 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -94,6 +94,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("ServerId", "SteamId"); + b.HasIndex("GuildId"); + b.ToTable("ClanPlayerNames"); }); @@ -159,6 +161,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("ServerId"); + b.HasIndex("GuildId"); + b.ToTable("ClanStates"); }); @@ -180,6 +184,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("ServerId"); + b.HasIndex("GuildId"); + b.ToTable("ServerCommandSettings"); }); @@ -205,6 +211,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("RustServerId"); + b.HasIndex("GuildId"); + b.ToTable("ConnectionStates"); }); @@ -325,6 +333,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("ServerId"); + b.HasIndex("GuildId"); + b.ToTable("ServerMapSettings"); }); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs index 69934663..c51ae0c1 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs @@ -2,11 +2,13 @@ using NSubstitute; using Persistord.Testing; using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Domain.Clans; using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; 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; @@ -58,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 @@ -81,11 +98,14 @@ public async Task PurgeGuild_RemovesTargetGuildRows_AndLeavesOtherGuildIntact() Assert.Empty(await context.ConnectionStates.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.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()); } /// diff --git a/tests/RustPlusBot.Persistence.Tests/BotDbContextTests.cs b/tests/RustPlusBot.Persistence.Tests/BotDbContextTests.cs index 7ed0eab7..a2cd5bad 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; @@ -55,6 +57,45 @@ 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 _ = context; + using var __ = database; + + 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 _ = context; + using var __ = database; + + 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() { From f50993c7186d94aa93ae81b4fb63b32c174b3754 Mon Sep 17 00:00:00 2001 From: = Date: Thu, 10 Sep 2026 17:06:12 +0200 Subject: [PATCH 4/6] feat: remember the chat webhooks the bot creates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiscordChatWebhookPoster re-discovered its webhook by name on every boot, so renaming one in Discord orphaned it and the bot silently created a duplicate alongside it — the exact failure Persistord.Managed's ManagedWebhook exists to prevent. The webhook id and token are now recorded per (guild, channel, kind) and a restart posts through the webhook it already owns. The name lookup stays as the fallback: a guild provisioned by an older build has no record yet, and it must adopt its existing webhook rather than create a second one. If the recorded webhook turns out to be unusable — deleted, or its token revoked, which the DiscordWebhookClient constructor surfaces — the record is dropped and that same fallback resolves a replacement. Only ManagedWebhookConfiguration is applied, not ApplyManagedModule: the bot owns its categories, channels and anchored messages through its own Provisioned* tables, whose scope is a real foreign key to RustServers rather than ManagedResource's opaque string, so mapping the other three would only add empty tables. The token is protected by the bot's own ICredentialProtector, the one that already covers player and FCM credentials; Persistord's [Protected] attribute on that column stays inert, and wiring Persistord.Protection later would have to drop the manual call in the same change. Because ManagedResource is IGuildScoped and timestamped, the new table joins the guild purge and the timestamp interceptor without any further wiring. Co-Authored-By: Claude Opus 5 (1M context) --- .../Relaying/ChatRelay.cs | 3 +- .../Webhooks/DiscordChatWebhookPoster.cs | 112 ++- .../Webhooks/IChatWebhookPoster.cs | 2 + src/RustPlusBot.Persistence/BotDbContext.cs | 12 +- .../Chat/ChatWebhookStore.cs | 104 ++ .../Chat/IChatWebhookStore.cs | 57 ++ .../20260910150057_ChatWebhooks.Designer.cs | 931 ++++++++++++++++++ .../Migrations/20260910150057_ChatWebhooks.cs | 48 + .../Migrations/BotDbContextModelSnapshot.cs | 43 + .../PersistenceServiceCollectionExtensions.cs | 2 + .../RustPlusBot.Persistence.csproj | 1 + .../ChatRegistrationTests.cs | 6 +- .../ChatRelayTests.cs | 30 +- .../Hosting/ChatHostedServiceTests.cs | 16 +- .../Chat/ChatWebhookStoreTests.cs | 156 +++ .../PersistenceRegistrationTests.cs | 2 + 16 files changed, 1487 insertions(+), 38 deletions(-) create mode 100644 src/RustPlusBot.Persistence/Chat/ChatWebhookStore.cs create mode 100644 src/RustPlusBot.Persistence/Chat/IChatWebhookStore.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260910150057_ChatWebhooks.Designer.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260910150057_ChatWebhooks.cs create mode 100644 tests/RustPlusBot.Persistence.Tests/Chat/ChatWebhookStoreTests.cs 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.Persistence/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs index 5e57d9d4..f00acab4 100644 --- a/src/RustPlusBot.Persistence/BotDbContext.cs +++ b/src/RustPlusBot.Persistence/BotDbContext.cs @@ -1,5 +1,7 @@ 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; @@ -83,6 +85,9 @@ 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) { @@ -113,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/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 56112a12..84250ffd 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -17,6 +17,49 @@ protected override void BuildModel(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") diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs index 4cd5c07e..6f565858 100644 --- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ 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; @@ -61,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/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs index b65179ba..f7f6b6fe 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,7 @@ 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..e3baac95 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,7 @@ 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 +87,7 @@ 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 +101,7 @@ 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 +116,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 +131,7 @@ 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 +145,7 @@ 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 +162,8 @@ 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 +179,7 @@ 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 +193,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 +207,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 +221,8 @@ 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 +237,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..3399de18 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs @@ -101,8 +101,8 @@ 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 +124,8 @@ 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 +150,7 @@ 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 +167,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 +194,7 @@ 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 +204,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.Persistence.Tests/Chat/ChatWebhookStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Chat/ChatWebhookStoreTests.cs new file mode 100644 index 00000000..1947397b --- /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 _ = context; + await using var __ = database; + 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 _ = context; + await using var __ = database; + 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 _ = context; + await using var __ = database; + 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 _ = context; + await using var __ = database; + 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 _ = context; + await using var __ = database; + 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 _ = context; + await using var __ = database; + + 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 _ = context; + await using var __ = database; + 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/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)); } } From 187ed837f2bdd42d0780f971b1822b2a318ec62b Mon Sep 17 00:00:00 2001 From: = Date: Thu, 10 Sep 2026 17:08:53 +0200 Subject: [PATCH 5/6] style: apply the ReformatAndReorder profile to the touched files Co-Authored-By: Claude Opus 5 (1M context) --- src/RustPlusBot.Domain/Alarms/SmartAlarm.cs | 12 +++--- .../Clans/ClanPlayerName.cs | 6 +-- src/RustPlusBot.Domain/Clans/ClanState.cs | 6 +-- .../Commands/ServerCommandSettings.cs | 6 +-- .../Connections/ConnectionState.cs | 6 +-- .../Credentials/FcmRegistration.cs | 6 +-- .../Credentials/PlayerCredential.cs | 6 +-- .../Devices/PairedDeviceEntity.cs | 10 ++--- .../Guilds/GuildSettings.cs | 6 +-- .../Map/ServerMapSettings.cs | 9 ++--- src/RustPlusBot.Domain/Servers/RustServer.cs | 6 +-- .../Vending/VendingGridTrack.cs | 6 +-- .../Vending/VendingListingTrack.cs | 6 +-- .../Vending/VendingNotification.cs | 6 +-- .../Vending/VendingStockNotification.cs | 6 +-- .../Workspace/ProvisionedCategory.cs | 6 +-- .../Workspace/ProvisionedChannel.cs | 6 +-- .../Workspace/ProvisionedMessage.cs | 6 +-- .../Clans/ClanStore.cs | 40 +++++++++---------- .../Vending/VendingStore.cs | 5 +-- .../ChatRegistrationTests.cs | 3 +- .../ChatRelayTests.cs | 24 +++++++---- .../Hosting/ChatHostedServiceTests.cs | 18 ++++++--- .../AlarmPrimingTests.cs | 2 +- .../AlarmSweepTests.cs | 2 +- .../ClanSupervisorTests.cs | 2 +- .../ConnectionSupervisorTests.cs | 2 +- .../MapImageQueryTests.cs | 2 +- .../ServerQueryTests.cs | 2 +- .../StorageMonitorPrimingTests.cs | 2 +- .../StorageSweepTests.cs | 2 +- .../SwitchPrimingTests.cs | 2 +- .../SwitchQueryTests.cs | 2 +- .../TeamChatSenderTests.cs | 2 +- .../PairingSupervisorTests.cs | 1 - .../Locating/AlarmChannelLocatorTests.cs | 2 +- .../Locating/CachingChannelLocatorTests.cs | 2 +- .../Locating/EventChannelLocatorTests.cs | 2 +- .../Locating/MapChannelLocatorTests.cs | 2 +- .../PlayerEventChannelLocatorTests.cs | 2 +- .../Locating/SetupChannelLocatorTests.cs | 2 +- .../StorageMonitorChannelLocatorTests.cs | 2 +- .../Locating/SwitchChannelLocatorTests.cs | 2 +- .../Locating/TeamChatChannelLocatorTests.cs | 2 +- .../Teardown/ServerPurgeServiceTests.cs | 1 - .../Credentials/FcmRegistrationStoreTests.cs | 4 +- 46 files changed, 133 insertions(+), 124 deletions(-) diff --git a/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs index 9bff5310..112e57b6 100644 --- a/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs +++ b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs @@ -9,9 +9,6 @@ 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; } @@ -27,9 +24,6 @@ public sealed class SmartAlarm : IGuildScoped, ICreatedAt /// The Discord user who accepted (validated) the pairing. public ulong PairedByUserId { get; set; } - /// When the alarm was accepted (UTC). Stamped by Persistord's TimestampInterceptor. - public DateTimeOffset CreatedAt { get; set; } - /// When true, a trigger going active pings @everyone in #alarms. public bool PingEveryone { get; set; } @@ -44,4 +38,10 @@ public sealed class SmartAlarm : IGuildScoped, ICreatedAt /// 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 bcedaab7..c5fed779 100644 --- a/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs +++ b/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs @@ -8,9 +8,6 @@ namespace RustPlusBot.Domain.Clans; /// 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; } @@ -20,6 +17,9 @@ public sealed class ClanPlayerName : IGuildScoped, IUpdatedAt /// The most recently observed display name. public string Name { get; set; } = string.Empty; + /// 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 7cf97f22..2982df17 100644 --- a/src/RustPlusBot.Domain/Clans/ClanState.cs +++ b/src/RustPlusBot.Domain/Clans/ClanState.cs @@ -8,9 +8,6 @@ namespace RustPlusBot.Domain.Clans; /// 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; } @@ -58,4 +55,7 @@ public sealed class ClanState : IGuildScoped /// 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 424c22c4..c73027f8 100644 --- a/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs +++ b/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs @@ -5,9 +5,6 @@ namespace RustPlusBot.Domain.Commands; /// Per-(guild, server) command configuration: trigger prefix and mute state. 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; } @@ -16,4 +13,7 @@ public sealed class ServerCommandSettings : IGuildScoped /// 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 81d005d3..f11c644c 100644 --- a/src/RustPlusBot.Domain/Connections/ConnectionState.cs +++ b/src/RustPlusBot.Domain/Connections/ConnectionState.cs @@ -8,9 +8,6 @@ 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; } @@ -20,6 +17,9 @@ public sealed class ConnectionState : IGuildScoped, IUpdatedAt /// Last heartbeat player count, or null if unknown. public int? PlayerCount { get; set; } + /// 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 32358a1c..9460a089 100644 --- a/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs +++ b/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs @@ -11,9 +11,6 @@ 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; } @@ -23,6 +20,9 @@ public sealed class FcmRegistration : IGuildScoped, IUpdatedAt /// Listener lifecycle state. public FcmRegistrationStatus Status { get; set; } = FcmRegistrationStatus.Active; + /// 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 1404a1f9..b25da250 100644 --- a/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs +++ b/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs @@ -11,9 +11,6 @@ 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; } @@ -28,4 +25,7 @@ public sealed class PlayerCredential : IGuildScoped /// 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 b36a27d2..6826d9ca 100644 --- a/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs +++ b/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs @@ -20,9 +20,6 @@ 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; } @@ -38,9 +35,12 @@ public abstract class PairedDeviceEntity : IGuildScoped, ICreatedAt /// The Discord user who accepted (validated) the pairing. public ulong PairedByUserId { 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; } - /// Per-device reachability; defaults to Reachable. Orthogonal to whole-server connection status. - public DeviceReachability Reachability { get; set; } = DeviceReachability.Reachable; + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } } diff --git a/src/RustPlusBot.Domain/Guilds/GuildSettings.cs b/src/RustPlusBot.Domain/Guilds/GuildSettings.cs index 2823937f..d8ef44a3 100644 --- a/src/RustPlusBot.Domain/Guilds/GuildSettings.cs +++ b/src/RustPlusBot.Domain/Guilds/GuildSettings.cs @@ -5,12 +5,12 @@ namespace RustPlusBot.Domain.Guilds; /// Per-guild configuration. Primary key is the guild snowflake. 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 851324d8..0804fee2 100644 --- a/src/RustPlusBot.Domain/Map/ServerMapSettings.cs +++ b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs @@ -1,15 +1,11 @@ -using RustPlusBot.Abstractions.Connections; - 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 : 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; } @@ -36,4 +32,7 @@ public sealed class ServerMapSettings : IGuildScoped /// 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/Servers/RustServer.cs b/src/RustPlusBot.Domain/Servers/RustServer.cs index cbcb2ed3..68a5e84a 100644 --- a/src/RustPlusBot.Domain/Servers/RustServer.cs +++ b/src/RustPlusBot.Domain/Servers/RustServer.cs @@ -8,9 +8,6 @@ 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; @@ -34,4 +31,7 @@ public sealed class RustServer : IGuildScoped /// 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 910cfae7..5ff11f1e 100644 --- a/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs +++ b/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs @@ -8,9 +8,6 @@ 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; } @@ -22,4 +19,7 @@ public sealed class VendingGridTrack : IGuildScoped, ICreatedAt /// 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 57e46934..a57de502 100644 --- a/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs +++ b/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs @@ -8,9 +8,6 @@ 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; } @@ -37,4 +34,7 @@ public sealed class VendingListingTrack : IGuildScoped, ICreatedAt /// 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 697f7931..31eb22e3 100644 --- a/src/RustPlusBot.Domain/Vending/VendingNotification.cs +++ b/src/RustPlusBot.Domain/Vending/VendingNotification.cs @@ -12,9 +12,6 @@ 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; } @@ -41,4 +38,7 @@ public sealed class VendingNotification : IGuildScoped /// 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 660b9b54..1522e069 100644 --- a/src/RustPlusBot.Domain/Vending/VendingStockNotification.cs +++ b/src/RustPlusBot.Domain/Vending/VendingStockNotification.cs @@ -11,9 +11,6 @@ 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; } @@ -31,4 +28,7 @@ public sealed class VendingStockNotification : IGuildScoped /// 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 9afe7fe5..0eb28aea 100644 --- a/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs @@ -8,9 +8,6 @@ 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; } @@ -19,4 +16,7 @@ public sealed class ProvisionedCategory : IGuildScoped, ICreatedAt /// 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 fc9e5a08..65f318b5 100644 --- a/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs @@ -8,9 +8,6 @@ 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; } @@ -22,4 +19,7 @@ public sealed class ProvisionedChannel : IGuildScoped, ICreatedAt /// 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 b57cb97d..9d609b45 100644 --- a/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs @@ -8,9 +8,6 @@ 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; } @@ -26,6 +23,9 @@ public sealed class ProvisionedMessage : IGuildScoped, ICreatedAt, IUpdatedAt /// 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; } + /// When the record was last written (UTC). Stamped by Persistord's TimestampInterceptor. public DateTimeOffset UpdatedAt { get; set; } } diff --git a/src/RustPlusBot.Persistence/Clans/ClanStore.cs b/src/RustPlusBot.Persistence/Clans/ClanStore.cs index 7235a33a..24ba8b0d 100644 --- a/src/RustPlusBot.Persistence/Clans/ClanStore.cs +++ b/src/RustPlusBot.Persistence/Clans/ClanStore.cs @@ -63,26 +63,6 @@ await db.ClanStates.UpsertAsync( .ConfigureAwait(false); } - private static void Apply(ClanState row, ulong guildId, ClanSnapshot snapshot, DateTimeOffset seenAt) - { - 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 = seenAt; - } - /// public async Task ClearAsync( ulong guildId, @@ -157,4 +137,24 @@ await db.ClanPlayerNames.UpsertAsync( cancellationToken) .ConfigureAwait(false); } + + private static void Apply(ClanState row, ulong guildId, ClanSnapshot snapshot, DateTimeOffset seenAt) + { + 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 = seenAt; + } } diff --git a/src/RustPlusBot.Persistence/Vending/VendingStore.cs b/src/RustPlusBot.Persistence/Vending/VendingStore.cs index 100ebd1e..fad4b056 100644 --- a/src/RustPlusBot.Persistence/Vending/VendingStore.cs +++ b/src/RustPlusBot.Persistence/Vending/VendingStore.cs @@ -42,10 +42,7 @@ await context.VendingGridTracks.UpsertAsync( g => g.GuildId == guildId && g.ServerId == serverId && g.Grid == normalized, () => new VendingGridTrack { - GuildId = guildId, - ServerId = serverId, - Grid = normalized, - RegisteredBySteamId = steamId, + GuildId = guildId, ServerId = serverId, Grid = normalized, RegisteredBySteamId = steamId, }, _ => { }, ct) diff --git a/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs index f7f6b6fe..64fbb631 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs @@ -83,7 +83,8 @@ await relay.RelayAsync( FromActivePlayer: true), CancellationToken.None); - await poster.DidNotReceive().PostAsync(Arg.Any(), 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 e3baac95..8d1457af 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs @@ -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(), 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, 10UL, 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(), Arg.Any(), + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); } @@ -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(), 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(), Arg.Any(), + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); } @@ -163,7 +168,8 @@ await relay.RelayAsync(new RelayedChatLine(kind, 10UL, Guid.Empty, "Bob", "!not await poster.Received(1) .PostAsync(kind, 10UL, ChannelFor(kind), "Bob", "!not a command", Arg.Any()); - await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), 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(), Arg.Any(), + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); } @@ -222,7 +229,8 @@ public async Task Posts_a_clan_line_to_the_clan_channel_not_the_team_channel() await poster.Received(1) .PostAsync(ChatChannelKind.Clan, 10UL, ClanChannel, "Bob", "hello", Arg.Any()); - await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), TeamChannel, 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()); } diff --git a/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs b/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs index 3399de18..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, Arg.Any(), TeamChannel, "dave", "hi team", Arg.Any()); - await poster.DidNotReceive().PostAsync(ChatChannelKind.Clan, Arg.Any(), 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, Arg.Any(), ClanChannel, "dave", "hi clan", Arg.Any()); - await poster.DidNotReceive().PostAsync(ChatChannelKind.Team, Arg.Any(), 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(), Arg.Any(), + poster.PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("simulated fault")); @@ -194,7 +199,8 @@ await bus.PublishAsync( await service.StopAsync(default); await poster.Received() - .PostAsync(ChatChannelKind.Clan, Arg.Any(), ClanChannel, "dave", "hi clan", Arg.Any()); + .PostAsync(ChatChannelKind.Clan, Arg.Any(), ClanChannel, "dave", "hi clan", + Arg.Any()); } [Fact] diff --git a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs index 0bc517f2..cabac8e6 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; diff --git a/tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs index 18a911ce..a80e903a 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs @@ -2,8 +2,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; diff --git a/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs index 4f5625fe..e2884446 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs index b329142f..fd507888 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs @@ -4,8 +4,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; diff --git a/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs index bef5f03a..a5427a41 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; diff --git a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs index 97d763c0..9f08edf4 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; diff --git a/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorPrimingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorPrimingTests.cs index c742038a..96484b87 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorPrimingTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorPrimingTests.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; diff --git a/tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs b/tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs index a6e2364a..6771322e 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; diff --git a/tests/RustPlusBot.Features.Connections.Tests/SwitchPrimingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/SwitchPrimingTests.cs index b9eaea08..e066e6d1 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/SwitchPrimingTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/SwitchPrimingTests.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; diff --git a/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs index 7a72f4f9..87b9381d 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs index 51a2c8f5..177f4061 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs index ff466c69..76806d11 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.Security.Cryptography; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/AlarmChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/AlarmChannelLocatorTests.cs index 1bd9a07b..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.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/CachingChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/CachingChannelLocatorTests.cs index 02a1d657..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.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/EventChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/EventChannelLocatorTests.cs index 232304bd..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.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/MapChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/MapChannelLocatorTests.cs index ceb4f26c..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.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/PlayerEventChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/PlayerEventChannelLocatorTests.cs index 2218d047..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.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/SetupChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/SetupChannelLocatorTests.cs index ee6f8f9f..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.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/StorageMonitorChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/StorageMonitorChannelLocatorTests.cs index 42ee43f2..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.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/SwitchChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/SwitchChannelLocatorTests.cs index 9731fcef..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.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs index c66ffa67..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.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Persistord.Testing; using NSubstitute; +using Persistord.Testing; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/ServerPurgeServiceTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/ServerPurgeServiceTests.cs index 93970722..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.EntityFrameworkCore; using NSubstitute; using Persistord.Testing; -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Workspace; using RustPlusBot.Features.Workspace.Gateway; diff --git a/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs index cda72fad..21c6d3f8 100644 --- a/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs @@ -8,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(); @@ -15,8 +17,6 @@ private static ICredentialProtector PassThroughProtector() return protector; } - private static readonly DateTimeOffset Now = new(2026, 6, 15, 0, 0, 0, TimeSpan.Zero); - [Fact] public async Task Upsert_StoresProtectedAndActive() { From daaabb86cc02d5f0b7a91cf1d01d749fcc07739f Mon Sep 17 00:00:00 2001 From: = Date: Thu, 10 Sep 2026 17:28:11 +0200 Subject: [PATCH 6/6] test: dispose each context before the database backing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disposal runs in reverse of declaration, so `using var _ = context;` followed by `using var __ = database;` tore the database down first and left the context outliving it. Harmless in practice — EF does not own a connection it was handed — but it is backwards, and the suite already declared it the other way round in about half its files. The fixture's second element is a SqliteTestDatabase now, not a SqliteConnection, so the locals that still called it `connection` are renamed to match what they hold. Addresses the two review comments on #91. Co-Authored-By: Claude Opus 5 (1M context) --- .../PairingHandlerTests.cs | 42 ++++++------- .../ServerPairingCoordinatorTests.cs | 48 +++++++-------- .../Alarms/SmartAlarmSchemaTests.cs | 12 ++-- .../BotDbContextTests.cs | 26 ++++---- .../Chat/ChatWebhookStoreTests.cs | 28 ++++----- .../Commands/MuteStoreTests.cs | 4 +- .../ServerCommandSettingsSchemaTests.cs | 12 ++-- .../Connections/ConnectionStateSchemaTests.cs | 18 +++--- .../Credentials/CredentialStoreTests.cs | 42 ++++++------- .../Credentials/FcmRegistrationStoreTests.cs | 30 +++++----- .../Credentials/PairingSchemaTests.cs | 18 +++--- .../DatabaseMaintenanceServiceTests.cs | 6 +- .../Map/MapSettingsStoreTests.cs | 42 ++++++------- .../Map/ServerMapSettingsSchemaTests.cs | 12 ++-- .../Servers/RustServerWipeColumnsTests.cs | 8 +-- .../Servers/ServerServiceTests.cs | 60 +++++++++---------- .../SmartStorageMonitorSchemaTests.cs | 12 ++-- .../Switches/SmartSwitchSchemaTests.cs | 12 ++-- .../Wipes/WipeBaselineStoreTests.cs | 4 +- .../Workspace/ProvisioningSchemaTests.cs | 12 ++-- .../Workspace/WorkspaceStoreByKeyTests.cs | 4 +- .../Workspace/WorkspaceStoreTests.cs | 4 +- .../Workspace/WorkspaceStoreWipePingTests.cs | 12 ++-- 23 files changed, 234 insertions(+), 234 deletions(-) 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/ServerPairingCoordinatorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs index a75c1392..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.Database; + 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.Database; + 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.Database; + 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.Database; + 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)); diff --git a/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs index 1f4786d6..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 { @@ -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 { diff --git a/tests/RustPlusBot.Persistence.Tests/BotDbContextTests.cs b/tests/RustPlusBot.Persistence.Tests/BotDbContextTests.cs index a2cd5bad..fa98782b 100644 --- a/tests/RustPlusBot.Persistence.Tests/BotDbContextTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/BotDbContextTests.cs @@ -16,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 @@ -35,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. @@ -66,8 +66,8 @@ public void PairedDeviceEntity_IsNotAnEntityType_SoTheDeviceTablesNeverCollapseI public void Model_KeepsTheShapePersistordsConventionsAssume() { var (context, database) = SqliteContextFixture.Create(); - using var _ = context; - using var __ = database; + using var _ = database; + using var __ = context; context.AssertSnowflakeKey(); context.AssertUniqueIndex(nameof(SmartSwitch.GuildId), nameof(SmartSwitch.ServerId), @@ -83,8 +83,8 @@ public void Model_KeepsTheShapePersistordsConventionsAssume() public void EveryMappedEntity_IsGuildScoped() { var (context, database) = SqliteContextFixture.Create(); - using var _ = context; - using var __ = database; + using var _ = database; + using var __ = context; var unscoped = context.Model.GetEntityTypes() .Where(e => !e.IsOwned()) @@ -99,9 +99,9 @@ public void EveryMappedEntity_IsGuildScoped() [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 index 1947397b..d26cd206 100644 --- a/tests/RustPlusBot.Persistence.Tests/Chat/ChatWebhookStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Chat/ChatWebhookStoreTests.cs @@ -26,8 +26,8 @@ private static ICredentialProtector PrefixingProtector() public async Task Save_then_Get_round_trips_the_webhook_and_stores_the_token_protected() { var (context, database) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = database; + await using var _ = database; + await using var __ = context; var store = new ChatWebhookStore(context, PrefixingProtector()); await store.SaveAsync(Guild, Channel, Key, 4242UL, "s3cret"); @@ -47,8 +47,8 @@ public async Task Save_then_Get_round_trips_the_webhook_and_stores_the_token_pro public async Task Get_returns_null_when_nothing_was_recorded() { var (context, database) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = database; + await using var _ = database; + await using var __ = context; var store = new ChatWebhookStore(context, PrefixingProtector()); Assert.Null(await store.GetAsync(Guild, Channel, Key)); @@ -62,8 +62,8 @@ public async Task Get_returns_null_when_nothing_was_recorded() public async Task Saving_again_replaces_the_recorded_webhook() { var (context, database) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = database; + await using var _ = database; + await using var __ = context; var store = new ChatWebhookStore(context, PrefixingProtector()); await store.SaveAsync(Guild, Channel, Key, 1UL, "first"); @@ -79,8 +79,8 @@ public async Task Saving_again_replaces_the_recorded_webhook() public async Task Records_are_kept_apart_per_channel_and_per_kind() { var (context, database) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = database; + await using var _ = database; + await using var __ = context; var store = new ChatWebhookStore(context, PrefixingProtector()); await store.SaveAsync(Guild, Channel, Key, 1UL, "team"); @@ -98,8 +98,8 @@ public async Task Records_are_kept_apart_per_channel_and_per_kind() public async Task Forget_drops_only_that_record() { var (context, database) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = database; + await using var _ = database; + await using var __ = context; var store = new ChatWebhookStore(context, PrefixingProtector()); await store.SaveAsync(Guild, Channel, Key, 1UL, "team"); @@ -120,8 +120,8 @@ public async Task Forget_drops_only_that_record() public async Task An_unreadable_token_reports_no_record_and_drops_it() { var (context, database) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = database; + await using var _ = database; + await using var __ = context; var protector = Substitute.For(); protector.Protect(Arg.Any()).Returns(call => call.Arg()); @@ -142,8 +142,8 @@ public async Task An_unreadable_token_reports_no_record_and_drops_it() public async Task Recorded_webhooks_are_stamped_and_guild_scoped() { var (context, database) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); - await using var _ = context; - await using var __ = database; + await using var _ = database; + await using var __ = context; var store = new ChatWebhookStore(context, PrefixingProtector()); await store.SaveAsync(Guild, Channel, Key, 1UL, "team"); diff --git a/tests/RustPlusBot.Persistence.Tests/Commands/MuteStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Commands/MuteStoreTests.cs index 22a00aa0..a3bcec8f 100644 --- a/tests/RustPlusBot.Persistence.Tests/Commands/MuteStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Commands/MuteStoreTests.cs @@ -8,8 +8,8 @@ public sealed class MuteStoreTests { 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/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 21c6d3f8..4496e923 100644 --- a/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs @@ -20,9 +20,9 @@ private static ICredentialProtector PassThroughProtector() [Fact] public async Task Upsert_StoresProtectedAndActive() { - var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); - await using var _ = context; - await using var __ = connection; + 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,9 +37,9 @@ public async Task Upsert_StoresProtectedAndActive() [Fact] public async Task Upsert_SameOwner_RefreshesAndReactivates() { - var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); - await using var _ = context; - await using var __ = connection; + 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"); @@ -56,9 +56,9 @@ public async Task Upsert_SameOwner_RefreshesAndReactivates() [Fact] public async Task ListActive_ReturnsOnlyActiveAcrossGuilds() { - var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); - await using var _ = context; - await using var __ = connection; + 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"); @@ -75,9 +75,9 @@ public async Task ListActive_ReturnsOnlyActiveAcrossGuilds() [Fact] public async Task SetStatus_UpdatesStatusAndTimestamp() { - var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); - await using var _ = context; - await using var __ = connection; + 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"); @@ -91,9 +91,9 @@ public async Task SetStatus_UpdatesStatusAndTimestamp() [Fact] public async Task Get_ReturnsRegistrationForOwner_OrNull() { - var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); - await using var _ = context; - await using var __ = connection; + 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/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/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/StorageMonitors/SmartStorageMonitorSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/StorageMonitors/SmartStorageMonitorSchemaTests.cs index aa61bc99..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 @@ -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 @@ -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 diff --git a/tests/RustPlusBot.Persistence.Tests/Switches/SmartSwitchSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Switches/SmartSwitchSchemaTests.cs index b815ce11..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 @@ -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 @@ -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 diff --git a/tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs index 24cd73cd..eee1468d 100644 --- a/tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs @@ -9,8 +9,8 @@ public sealed class WipeBaselineStoreTests { 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 c63b752f..0d3aacef 100644 --- a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs @@ -8,9 +8,9 @@ public sealed class WorkspaceStoreByKeyTests { private static WorkspaceStore NewStore(out BotDbContext context, out IDisposable cleanup) { - var (ctx, connection) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); + var (ctx, database) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); context = ctx; - cleanup = connection; + cleanup = database; return new WorkspaceStore(ctx); } diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs index 46e89ac0..82f44c88 100644 --- a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs @@ -7,9 +7,9 @@ public sealed class WorkspaceStoreTests { private static WorkspaceStore NewStore(out BotDbContext context, out IDisposable cleanup) { - var (ctx, connection) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); + var (ctx, database) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); context = ctx; - cleanup = connection; + cleanup = database; return new WorkspaceStore(ctx); } diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs index a4f75ff8..c053dfbd 100644 --- a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs @@ -8,8 +8,8 @@ public sealed class WorkspaceStoreWipePingTests [Fact] public async Task Ping_defaults_false_without_settings_row() { - var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); - 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); @@ -19,8 +19,8 @@ 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(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); - 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); @@ -34,8 +34,8 @@ 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(new FixedTimeProvider(DateTimeOffset.UnixEpoch)); - 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); await store.SetCultureAsync(10UL, "fr");