8f0f2ef7e7
Moves the game-creation wizard state machine, view builder, and platform-neutral contracts (callback data, step names, storage exception, club option, step limits) from GmRelay.Bot to GmRelay.Shared. Telegram continues to work through a new TelegramWizardMessenger implementing IWizardMessenger and a WizardInteractionMapper that converts Update → WizardInteraction. Wires the new platform column on wizard_drafts (V032 migration) and switches chat/owner/thread/message ids to TEXT so the same table can hold Discord snowflakes later. - GameCreationWizard: now in Shared, takes IWizardMessenger + IWizardDraftRepository, dispatches on WizardInteraction. - New IWizardMessenger contract with Edit/Send/Answer/GetOwnerClubs (returns string ids so Telegram longs and Discord snowflakes both fit). - New WizardStepViewBuilder in Shared returns (text, IReadOnlyList<WizardAction>); TelegramWizardMessenger renders actions into InlineKeyboardMarkup via a new Bot-side ToInlineKeyboard helper. - New WizardInteractionMapper in Bot (5-case test) converts Telegram Update to WizardInteraction. - WizardDraft gains a Platform column; ChatId/MessageThreadId/OwnerId/ DraftMessageId switched to string. V032 migrates existing rows and rebuilds the owner lookup index on (platform, owner_id). - All existing wizard / create-session tests updated to the new contract (HandleInteractionAsync + WizardInteraction). Wizard callback-data format preserved. - dotnet build clean, dotnet format --verify-no-changes clean, all 101 wizard tests pass.
120 lines
4.5 KiB
C#
120 lines
4.5 KiB
C#
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using GmRelay.Bot.Infrastructure.Telegram;
|
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
|
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: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture));
|
|
|
|
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: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture));
|
|
|
|
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;
|
|
}
|
|
}
|