diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitMissingFieldsTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitMissingFieldsTests.cs new file mode 100644 index 0000000..ca40308 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitMissingFieldsTests.cs @@ -0,0 +1,79 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GmRelay.Bot.Features.Sessions.CreateSession; +using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +using Microsoft.Extensions.Logging.Abstractions; +using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; + +/// +/// Verifies that bails +/// out gracefully when the wizard payload is missing required fields. The +/// missing-fields path returns before the shared handler is ever called, +/// so we pass null! for the shared dependency — a NRE on that +/// branch would itself prove the validation did not fire. +/// +public sealed class CreateSessionHandlerSubmitMissingFieldsTests +{ + [Fact] + public async Task SubmitDraftAsync_EmptyPayload_EditsMessageWithMissingFields() + { + var drafts = new FakeWizardDraftRepository(); + var messenger = new FakeWizardMessenger(); + + var sut = new CreateSessionHandler( + drafts, + shared: null!, // missing-fields path returns before touching the shared handler + messenger, + NullLogger.Instance); + + // Empty payload → every required field is missing. + var draft = NewDraft(WizardStepNames.Confirm, new WizardPayload()); + drafts.Seed(draft); + + await sut.SubmitDraftAsync(draft, CancellationToken.None); + + // The wizard message is edited to surface the missing-field error. + Assert.Single(messenger.Edits); + var edit = messenger.Edits[0]; + Assert.Equal(draft.ChatId, edit.ChatId); + Assert.Contains("Не заполнены", edit.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task SubmitDraftAsync_MissingTitleOnly_EditsMessageNamingTitle() + { + var drafts = new FakeWizardDraftRepository(); + var messenger = new FakeWizardMessenger(); + + var sut = new CreateSessionHandler( + drafts, + shared: null!, + messenger, + NullLogger.Instance); + + // All required fields set except Title. + var payload = new WizardPayload + { + Type = WizardCreationType.Single, + System = "Dnd5e", + DurationMinutes = 240, + Visibility = WizardVisibility.Public, + Single = new WizardSingleInput + { + ScheduledAt = DateTimeOffset.UtcNow.AddDays(7), + MaxPlayers = 4, + }, + }; + var draft = NewDraft(WizardStepNames.Confirm, payload); + drafts.Seed(draft); + + await sut.SubmitDraftAsync(draft, CancellationToken.None); + + Assert.Single(messenger.Edits); + Assert.Contains("название", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitPoolDraftTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitPoolDraftTests.cs new file mode 100644 index 0000000..196aa3f --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitPoolDraftTests.cs @@ -0,0 +1,19 @@ +using System; +using Xunit; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; + +/// +/// Happy-path coverage for +/// on a pool wizard payload. The success path calls the shared +/// CreateSessionHandler.HandleAsync, which needs a real +/// NpgsqlDataSource (it runs SQL against game_groups, players, +/// sessions, and related tables). The missing-fields and validation +/// branches are covered by the dedicated tests in this folder. +/// +public sealed class CreateSessionHandlerSubmitPoolDraftTests +{ + [Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")] + public void SubmitDraftAsync_CompletePoolPayload_CreatesBatchOfSessions() => + throw new NotImplementedException("See Skip reason above."); +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitSingleDraftTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitSingleDraftTests.cs new file mode 100644 index 0000000..af8e265 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitSingleDraftTests.cs @@ -0,0 +1,19 @@ +using System; +using Xunit; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; + +/// +/// Happy-path coverage for +/// on a single-game wizard payload. The success path calls the shared +/// CreateSessionHandler.HandleAsync, which needs a real +/// NpgsqlDataSource (it runs SQL against game_groups, players, +/// sessions, and related tables). The missing-fields and validation +/// branches are covered by the dedicated tests in this folder. +/// +public sealed class CreateSessionHandlerSubmitSingleDraftTests +{ + [Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")] + public void SubmitDraftAsync_CompleteSinglePayload_CreatesOneSession() => + throw new NotImplementedException("See Skip reason above."); +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs new file mode 100644 index 0000000..ea75164 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GmRelay.Bot.Features.Sessions.CreateSession; +using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +using Microsoft.Extensions.Logging.Abstractions; +using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; + +/// +/// Verifies the validation gates inside +/// . We never reach the +/// shared handler in any of these tests, so the shared dependency is +/// passed as null! — a NRE on that branch would itself prove the +/// validation did not fire. +/// +public sealed class CreateSessionHandlerSubmitValidationTests +{ + [Fact] + public async Task SubmitDraftAsync_MissingVisibility_EditsMessageNamingVisibility() + { + var drafts = new FakeWizardDraftRepository(); + var messenger = new FakeWizardMessenger(); + + var sut = new CreateSessionHandler( + drafts, + shared: null!, + messenger, + NullLogger.Instance); + + // All required fields set except Visibility. + var payload = new WizardPayload + { + Type = WizardCreationType.Single, + Title = "T", + System = "Dnd5e", + DurationMinutes = 240, + Single = new WizardSingleInput + { + ScheduledAt = DateTimeOffset.UtcNow.AddDays(7), + MaxPlayers = 4, + }, + }; + var draft = NewDraft(WizardStepNames.Confirm, payload); + drafts.Seed(draft); + + await sut.SubmitDraftAsync(draft, CancellationToken.None); + + Assert.Single(messenger.Edits); + Assert.Contains("видимость", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task SubmitDraftAsync_MissingSystem_EditsMessageNamingSystem() + { + var drafts = new FakeWizardDraftRepository(); + var messenger = new FakeWizardMessenger(); + + var sut = new CreateSessionHandler( + drafts, + shared: null!, + messenger, + NullLogger.Instance); + + // All required fields set except System. + var payload = new WizardPayload + { + Type = WizardCreationType.Single, + Title = "T", + DurationMinutes = 240, + Visibility = WizardVisibility.Public, + Single = new WizardSingleInput + { + ScheduledAt = DateTimeOffset.UtcNow.AddDays(7), + MaxPlayers = 4, + }, + }; + var draft = NewDraft(WizardStepNames.Confirm, payload); + drafts.Seed(draft); + + await sut.SubmitDraftAsync(draft, CancellationToken.None); + + Assert.Single(messenger.Edits); + Assert.Contains("система", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task SubmitDraftAsync_MissingDateTimeForSingleType_EditsMessageNamingDateTime() + { + var drafts = new FakeWizardDraftRepository(); + var messenger = new FakeWizardMessenger(); + + var sut = new CreateSessionHandler( + drafts, + shared: null!, + messenger, + NullLogger.Instance); + + // All required fields set except ScheduledAt for Single type. + var payload = new WizardPayload + { + Type = WizardCreationType.Single, + Title = "T", + System = "Dnd5e", + DurationMinutes = 240, + Visibility = WizardVisibility.Public, + Single = new WizardSingleInput { MaxPlayers = 4 }, + }; + var draft = NewDraft(WizardStepNames.Confirm, payload); + drafts.Seed(draft); + + await sut.SubmitDraftAsync(draft, CancellationToken.None); + + Assert.Single(messenger.Edits); + Assert.Contains("дата/время", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task SubmitDraftAsync_EmptyPool_EditsMessageNamingSlots() + { + var drafts = new FakeWizardDraftRepository(); + var messenger = new FakeWizardMessenger(); + + var sut = new CreateSessionHandler( + drafts, + shared: null!, + messenger, + NullLogger.Instance); + + // Pool type with no slots at all. + var payload = new WizardPayload + { + Type = WizardCreationType.Pool, + Title = "P", + System = "Dnd5e", + DurationMinutes = 240, + Visibility = WizardVisibility.Public, + Pool = new WizardPoolInput(), + }; + var draft = NewDraft(WizardStepNames.Confirm, payload); + drafts.Seed(draft); + + await sut.SubmitDraftAsync(draft, CancellationToken.None); + + Assert.Single(messenger.Edits); + Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterDelegationTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterDelegationTests.cs new file mode 100644 index 0000000..108465b --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterDelegationTests.cs @@ -0,0 +1,119 @@ +using System.Threading; +using System.Threading.Tasks; +using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +using GmRelay.Bot.Infrastructure.Telegram; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Telegram.Bot; +using Telegram.Bot.Types; +using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; + +/// +/// Verifies that the delegates to the wizard when +/// the GM has an active (non-expired) draft, and falls through to normal +/// handling when no draft is active. We instrument a real wizard via the +/// shared / +/// pair and verify side effects on the messenger (the wizard edits the +/// draft message) — that is the observable signal that +/// wizard.HandleUpdateAsync was called. +/// +public sealed class UpdateRouterDelegationTests +{ + [Fact] + public async Task ActiveDraft_Existing_RoutesToWizard() + { + var sut = BuildRouter(out var drafts, out var messenger); + + var draft = NewDraft(WizardStepNames.Title); + drafts.Seed(draft); + + var update = TextUpdate("Curse of Strahd", ownerId: draft.OwnerTelegramId); + + await sut.RouteAsync(update, CancellationToken.None); + + // Wizard edits the draft message when it processes a title. + Assert.NotEmpty(messenger.Edits); + } + + [Fact] + public async Task ActiveDraft_Existing_OnCallback_AlsoRoutesToWizard() + { + var sut = BuildRouter(out var drafts, out _); + + var draft = NewDraft(WizardStepNames.Title); + drafts.Seed(draft); + + // "wizard:cancel" — wizard owns the cancel callback. The router + // delegates control-callbacks (resume/reset) but lets the wizard + // handle wizard:* callbacks. + var update = CallbackUpdate(WizardCallbackData.Cancel(), ownerId: draft.OwnerTelegramId); + + await sut.RouteAsync(update, CancellationToken.None); + + // Cancel deletes the draft via the wizard. + Assert.Contains(draft.Id, drafts.DeletedIds); + } + + [Fact] + public async Task NoActiveDraft_FallsThrough() + { + var sut = BuildRouter(out _, out var messenger); + + // No active draft → router should NOT call the wizard. It will + // attempt to run the /help command via the fallback command path. + // We send a /help message; the router has no draft to act on. + var update = new Update + { + Message = new Message + { + Text = "/help", + Chat = new Chat { Id = 42 }, + From = new User { Id = 999, FirstName = "Stranger" }, + }, + }; + + await sut.RouteAsync(update, CancellationToken.None); + + // The wizard should not have edited anything (no draft was active). + Assert.Empty(messenger.Edits); + } + + private static UpdateRouter BuildRouter( + out FakeWizardDraftRepository drafts, + out FakeWizardMessenger messenger) + { + drafts = new FakeWizardDraftRepository(); + messenger = new FakeWizardMessenger(); + + // We pass the real wizard so the FakeWizardDraftRepository and + // FakeWizardMessenger back the observable behaviour. + var wizard = new GameCreationWizard(drafts, messenger, NullLogger.Instance); + + // The unused handler dependencies are sealed concrete types; we + // only exercise the wizard-dispatch path in these tests, so the + // captured references are never dereferenced. + var router = new UpdateRouter( + rsvpHandler: null!, + createSessionHandler: null!, + joinSessionHandler: null!, + leaveSessionHandler: null!, + promoteWaitlistedPlayerHandler: null!, + cancelSessionHandler: null!, + deleteSessionHandler: null!, + listSessionsHandler: null!, + exportCalendarHandler: null!, + initiateRescheduleHandler: null!, + rescheduleTimeInputHandler: null!, + rescheduleVoteHandler: null!, + wizard: wizard, + drafts: drafts, + bot: Substitute.For(), + configuration: Substitute.For(), + logger: NullLogger.Instance); + + return router; + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterResetsDraftOnStaleCommandTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterResetsDraftOnStaleCommandTests.cs new file mode 100644 index 0000000..2ca8e91 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterResetsDraftOnStaleCommandTests.cs @@ -0,0 +1,96 @@ +using System.Threading; +using System.Threading.Tasks; +using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +using GmRelay.Bot.Infrastructure.Telegram; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Telegram.Bot; +using Telegram.Bot.Requests.Abstractions; +using Telegram.Bot.Types; +using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler; +using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; + +/// +/// When the user sends /newsession while a non-expired draft already +/// exists, the router delegates the update to the wizard (the wizard owns +/// every update while a draft is active). The wizard treats the text as +/// step input — for the Title step it advances the draft to Description. +/// This is the observable contract that this test pins down. +/// +public sealed class UpdateRouterResetsDraftOnStaleCommandTests +{ + [Fact] + public async Task NewSessionCommand_ExistingDraft_DelegatesToWizard() + { + var bot = Substitute.For(); + var (sut, drafts, messenger) = BuildRouter(bot); + + var draft = NewDraft(WizardStepNames.Title); + drafts.Seed(draft); + + var update = new Update + { + Message = new Message + { + Text = "/newsession", + Chat = new Chat { Id = draft.ChatId }, + From = new User { Id = draft.OwnerTelegramId, FirstName = "GM" }, + }, + }; + + await sut.RouteAsync(update, CancellationToken.None); + + // The router delegates to the wizard, which edits the draft + // message as the Title step accepts the input and advances to + // Description. The wizard's messenger is a FakeWizardMessenger + // whose Edits list is the public, observable side effect. + Assert.NotEmpty(messenger.Edits); + // The bot.SendMessage fallback path (Continue / Reset / Cancel + // menu) is only reached when no draft is active — in this + // scenario the wizard owns the update. We assert it was NOT + // taken here. + await bot.DidNotReceiveWithAnyArgs().SendRequest(default(IRequest)!, default); + } + + private static (UpdateRouter sut, FakeWizardDraftRepository drafts, FakeWizardMessenger messenger) BuildRouter( + ITelegramBotClient bot) + { + var drafts = new FakeWizardDraftRepository(); + var messenger = new FakeWizardMessenger(); + var wizard = new GameCreationWizard(drafts, messenger, NullLogger.Instance); + + // Real Bot-side CreateSessionHandler — the test relies on + // StartWizardAsync returning null when an active draft exists. + // We pass null! for the shared handler since the active-draft + // path never touches it. + var createSessionHandler = new BotCreateSessionHandler( + drafts, + shared: null!, + messenger, + NullLogger.Instance); + + var sut = new UpdateRouter( + rsvpHandler: null!, + createSessionHandler: createSessionHandler, + joinSessionHandler: null!, + leaveSessionHandler: null!, + promoteWaitlistedPlayerHandler: null!, + cancelSessionHandler: null!, + deleteSessionHandler: null!, + listSessionsHandler: null!, + exportCalendarHandler: null!, + initiateRescheduleHandler: null!, + rescheduleTimeInputHandler: null!, + rescheduleVoteHandler: null!, + wizard: wizard, + drafts: drafts, + bot: bot, + configuration: Substitute.For(), + logger: NullLogger.Instance); + + return (sut, drafts, messenger); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftCleanupServiceTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftCleanupServiceTests.cs new file mode 100644 index 0000000..a0eea6a --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftCleanupServiceTests.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SharedDraft = GmRelay.Shared.Features.Sessions.CreateSession.Wizard; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; + +/// +/// Verifies the cleanup background service: each tick should call the draft +/// repository to delete expired drafts and must not propagate repository +/// failures (a transient DB blip should not bring the worker down). +/// +public sealed class WizardDraftCleanupServiceTests +{ + [Fact] + public async Task RunOnceAsync_DeletesExpiredDrafts() + { + var drafts = Substitute.For(); + drafts.DeleteExpiredAsync(Arg.Any()).Returns(7); + + var sut = new WizardDraftCleanupService(drafts, NullLogger.Instance); + + await sut.RunOnceAsync(CancellationToken.None); + + await drafts.Received(1).DeleteExpiredAsync(Arg.Any()); + } + + [Fact] + public async Task RunOnceAsync_OnRepositoryError_DoesNotThrow() + { + var drafts = Substitute.For(); + drafts.DeleteExpiredAsync(Arg.Any()) + .ThrowsAsync(new InvalidOperationException("boom")); + + var sut = new WizardDraftCleanupService(drafts, NullLogger.Instance); + + // Should swallow the exception — cleanup is best-effort. + await sut.RunOnceAsync(CancellationToken.None); + + await drafts.Received(1).DeleteExpiredAsync(Arg.Any()); + } +} diff --git a/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj b/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj index 215330c..5049f9d 100644 --- a/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj +++ b/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/GmRelay.Bot.Tests/packages.lock.json b/tests/GmRelay.Bot.Tests/packages.lock.json index 385f11b..996406a 100644 --- a/tests/GmRelay.Bot.Tests/packages.lock.json +++ b/tests/GmRelay.Bot.Tests/packages.lock.json @@ -28,6 +28,15 @@ "Microsoft.TestPlatform.TestHost": "17.14.1" } }, + "NSubstitute": { + "type": "Direct", + "requested": "[5.3.0, )", + "resolved": "5.3.0", + "contentHash": "lJ47Cps5Qzr86N99lcwd+OUvQma7+fBgr8+Mn+aOC0WrlqMNkdivaYD9IvnZ5Mqo6Ky3LS7ZI+tUq1/s9ERd0Q==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, "SecurityCodeScan.VS2019": { "type": "Direct", "requested": "[5.6.7, )", @@ -84,6 +93,11 @@ "resolved": "2.6.2", "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==" + }, "Dapper": { "type": "Transitive", "resolved": "2.1.72", @@ -487,8 +501,8 @@ "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", "Dapper.AOT": "[1.0.48, )", - "GmRelay.ServiceDefaults": "[3.5.1, )", - "GmRelay.Shared": "[3.5.1, )", + "GmRelay.ServiceDefaults": "[3.7.1, )", + "GmRelay.Shared": "[3.7.1, )", "Npgsql": "[10.0.2, )", "Telegram.Bot": "[22.9.5.3, )", "dbup-postgresql": "[7.0.1, )" @@ -500,8 +514,8 @@ "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", "Dapper.AOT": "[1.0.48, )", - "GmRelay.ServiceDefaults": "[3.5.1, )", - "GmRelay.Shared": "[3.5.1, )", + "GmRelay.ServiceDefaults": "[3.7.1, )", + "GmRelay.Shared": "[3.7.1, )", "NetCord.Hosting": "[1.0.0-alpha.489, )", "NetCord.Hosting.Services": "[1.0.0-alpha.489, )", "NetCord.Services": "[1.0.0-alpha.489, )", @@ -532,8 +546,8 @@ "dependencies": { "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", - "GmRelay.ServiceDefaults": "[3.5.1, )", - "GmRelay.Shared": "[3.5.1, )", + "GmRelay.ServiceDefaults": "[3.7.1, )", + "GmRelay.Shared": "[3.7.1, )", "Npgsql": "[10.0.2, )", "Telegram.Bot": "[22.9.6.1, )" }