test(wizard): add submit, cleanup, router delegation tests
This commit is contained in:
+79
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that <see cref="CreateSessionHandler.SubmitDraftAsync"/> 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 <c>null!</c> for the shared dependency — a NRE on that
|
||||||
|
/// branch would itself prove the validation did not fire.
|
||||||
|
/// </summary>
|
||||||
|
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<CreateSessionHandler>.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<CreateSessionHandler>.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
using System;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
|
||||||
|
/// on a pool wizard payload. The success path calls the shared
|
||||||
|
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
|
||||||
|
/// <c>NpgsqlDataSource</c> (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.
|
||||||
|
/// </summary>
|
||||||
|
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.");
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
using System;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
|
||||||
|
/// on a single-game wizard payload. The success path calls the shared
|
||||||
|
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
|
||||||
|
/// <c>NpgsqlDataSource</c> (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.
|
||||||
|
/// </summary>
|
||||||
|
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.");
|
||||||
|
}
|
||||||
+150
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the validation gates inside
|
||||||
|
/// <see cref="CreateSessionHandler.SubmitDraftAsync"/>. We never reach the
|
||||||
|
/// shared handler in any of these tests, so the shared dependency is
|
||||||
|
/// passed as <c>null!</c> — a NRE on that branch would itself prove the
|
||||||
|
/// validation did not fire.
|
||||||
|
/// </summary>
|
||||||
|
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<CreateSessionHandler>.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<CreateSessionHandler>.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<CreateSessionHandler>.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<CreateSessionHandler>.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that the <see cref="UpdateRouter"/> 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 <see cref="FakeWizardDraftRepository"/>/<see cref="FakeWizardMessenger"/>
|
||||||
|
/// pair and verify side effects on the messenger (the wizard edits the
|
||||||
|
/// draft message) — that is the observable signal that
|
||||||
|
/// <c>wizard.HandleUpdateAsync</c> was called.
|
||||||
|
/// </summary>
|
||||||
|
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<GameCreationWizard>.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<ITelegramBotClient>(),
|
||||||
|
configuration: Substitute.For<IConfiguration>(),
|
||||||
|
logger: NullLogger<UpdateRouter>.Instance);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the user sends <c>/newsession</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdateRouterResetsDraftOnStaleCommandTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task NewSessionCommand_ExistingDraft_DelegatesToWizard()
|
||||||
|
{
|
||||||
|
var bot = Substitute.For<ITelegramBotClient>();
|
||||||
|
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<Message>)!, 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<GameCreationWizard>.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<BotCreateSessionHandler>.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<IConfiguration>(),
|
||||||
|
logger: NullLogger<UpdateRouter>.Instance);
|
||||||
|
|
||||||
|
return (sut, drafts, messenger);
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WizardDraftCleanupServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task RunOnceAsync_DeletesExpiredDrafts()
|
||||||
|
{
|
||||||
|
var drafts = Substitute.For<SharedDraft.IWizardDraftRepository>();
|
||||||
|
drafts.DeleteExpiredAsync(Arg.Any<CancellationToken>()).Returns(7);
|
||||||
|
|
||||||
|
var sut = new WizardDraftCleanupService(drafts, NullLogger<WizardDraftCleanupService>.Instance);
|
||||||
|
|
||||||
|
await sut.RunOnceAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await drafts.Received(1).DeleteExpiredAsync(Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunOnceAsync_OnRepositoryError_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var drafts = Substitute.For<SharedDraft.IWizardDraftRepository>();
|
||||||
|
drafts.DeleteExpiredAsync(Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("boom"));
|
||||||
|
|
||||||
|
var sut = new WizardDraftCleanupService(drafts, NullLogger<WizardDraftCleanupService>.Instance);
|
||||||
|
|
||||||
|
// Should swallow the exception — cleanup is best-effort.
|
||||||
|
await sut.RunOnceAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await drafts.Received(1).DeleteExpiredAsync(Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.12.0" />
|
<PackageReference Include="Testcontainers.PostgreSql" Version="4.12.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
|||||||
@@ -28,6 +28,15 @@
|
|||||||
"Microsoft.TestPlatform.TestHost": "17.14.1"
|
"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": {
|
"SecurityCodeScan.VS2019": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[5.6.7, )",
|
"requested": "[5.6.7, )",
|
||||||
@@ -84,6 +93,11 @@
|
|||||||
"resolved": "2.6.2",
|
"resolved": "2.6.2",
|
||||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||||
},
|
},
|
||||||
|
"Castle.Core": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "5.1.1",
|
||||||
|
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g=="
|
||||||
|
},
|
||||||
"Dapper": {
|
"Dapper": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "2.1.72",
|
"resolved": "2.1.72",
|
||||||
@@ -487,8 +501,8 @@
|
|||||||
"Aspire.Npgsql": "[13.2.2, )",
|
"Aspire.Npgsql": "[13.2.2, )",
|
||||||
"Dapper": "[2.1.72, )",
|
"Dapper": "[2.1.72, )",
|
||||||
"Dapper.AOT": "[1.0.48, )",
|
"Dapper.AOT": "[1.0.48, )",
|
||||||
"GmRelay.ServiceDefaults": "[3.5.1, )",
|
"GmRelay.ServiceDefaults": "[3.7.1, )",
|
||||||
"GmRelay.Shared": "[3.5.1, )",
|
"GmRelay.Shared": "[3.7.1, )",
|
||||||
"Npgsql": "[10.0.2, )",
|
"Npgsql": "[10.0.2, )",
|
||||||
"Telegram.Bot": "[22.9.5.3, )",
|
"Telegram.Bot": "[22.9.5.3, )",
|
||||||
"dbup-postgresql": "[7.0.1, )"
|
"dbup-postgresql": "[7.0.1, )"
|
||||||
@@ -500,8 +514,8 @@
|
|||||||
"Aspire.Npgsql": "[13.2.2, )",
|
"Aspire.Npgsql": "[13.2.2, )",
|
||||||
"Dapper": "[2.1.72, )",
|
"Dapper": "[2.1.72, )",
|
||||||
"Dapper.AOT": "[1.0.48, )",
|
"Dapper.AOT": "[1.0.48, )",
|
||||||
"GmRelay.ServiceDefaults": "[3.5.1, )",
|
"GmRelay.ServiceDefaults": "[3.7.1, )",
|
||||||
"GmRelay.Shared": "[3.5.1, )",
|
"GmRelay.Shared": "[3.7.1, )",
|
||||||
"NetCord.Hosting": "[1.0.0-alpha.489, )",
|
"NetCord.Hosting": "[1.0.0-alpha.489, )",
|
||||||
"NetCord.Hosting.Services": "[1.0.0-alpha.489, )",
|
"NetCord.Hosting.Services": "[1.0.0-alpha.489, )",
|
||||||
"NetCord.Services": "[1.0.0-alpha.489, )",
|
"NetCord.Services": "[1.0.0-alpha.489, )",
|
||||||
@@ -532,8 +546,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Aspire.Npgsql": "[13.2.2, )",
|
"Aspire.Npgsql": "[13.2.2, )",
|
||||||
"Dapper": "[2.1.72, )",
|
"Dapper": "[2.1.72, )",
|
||||||
"GmRelay.ServiceDefaults": "[3.5.1, )",
|
"GmRelay.ServiceDefaults": "[3.7.1, )",
|
||||||
"GmRelay.Shared": "[3.5.1, )",
|
"GmRelay.Shared": "[3.7.1, )",
|
||||||
"Npgsql": "[10.0.2, )",
|
"Npgsql": "[10.0.2, )",
|
||||||
"Telegram.Bot": "[22.9.6.1, )"
|
"Telegram.Bot": "[22.9.6.1, )"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user