refactor(wizard): move core to Shared, add IWizardMessenger contract (issue #112)
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.
This commit is contained in:
+112
-38
@@ -1,25 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
using WizardBot = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
||||
using WizardMessenger = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.ITelegramWizardMessenger;
|
||||
using WizardBot = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Hand-rolled test doubles and helpers for wizard unit tests. The project
|
||||
/// convention is to use fakes (not a mocking framework) so the suite stays
|
||||
/// AOT-friendly and the production code doesn't grow virtual members just
|
||||
/// for tests.
|
||||
/// Hand-rolled test doubles and helpers for wizard unit tests. The
|
||||
/// project convention is to use fakes (not a mocking framework) so the
|
||||
/// suite stays AOT-friendly and the production code doesn't grow
|
||||
/// virtual members just for tests.
|
||||
/// </summary>
|
||||
internal static class WizardTestFakes
|
||||
{
|
||||
public const string PlatformName = "Telegram";
|
||||
|
||||
public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger)
|
||||
{
|
||||
drafts = new FakeWizardDraftRepository();
|
||||
@@ -30,11 +31,12 @@ internal static class WizardTestFakes
|
||||
public static WizardDraft NewDraft(string step, WizardPayload? payload = null, long ownerId = 100) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = 42,
|
||||
ChatId = "42",
|
||||
MessageThreadId = null,
|
||||
OwnerTelegramId = ownerId,
|
||||
OwnerId = ownerId.ToString(CultureInfo.InvariantCulture),
|
||||
Platform = PlatformName,
|
||||
Step = step,
|
||||
DraftMessageId = 7,
|
||||
DraftMessageId = "7",
|
||||
PayloadJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
payload ?? new WizardPayload(),
|
||||
WizardPayloadJsonContext.Default.WizardPayload),
|
||||
@@ -43,6 +45,63 @@ internal static class WizardTestFakes
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build the platform-neutral <see cref="WizardInteraction"/> the
|
||||
/// wizard now consumes. Pre-V112 callers passed
|
||||
/// <c>Telegram.Bot.Types.Update</c> directly; tests now build the
|
||||
/// neutral interaction via the same mapper the production code uses.
|
||||
/// </summary>
|
||||
public static WizardInteraction CallbackInteraction(
|
||||
string data, string ownerId = "100", string callbackId = "cb-1")
|
||||
{
|
||||
return new WizardInteraction(
|
||||
OwnerId: ownerId,
|
||||
Text: null,
|
||||
CallbackPayload: data,
|
||||
PhotoFileId: null,
|
||||
PhotoUrl: null,
|
||||
InteractionId: callbackId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a text-style <see cref="WizardInteraction"/> mirroring what
|
||||
/// <c>WizardInteractionMapper</c> would produce for a Telegram text
|
||||
/// message.
|
||||
/// </summary>
|
||||
public static WizardInteraction TextInteraction(
|
||||
string text, string ownerId = "100", int messageId = 1)
|
||||
{
|
||||
return new WizardInteraction(
|
||||
OwnerId: ownerId,
|
||||
Text: text,
|
||||
CallbackPayload: null,
|
||||
PhotoFileId: null,
|
||||
PhotoUrl: null,
|
||||
InteractionId: $"msg-{messageId}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a photo-style <see cref="WizardInteraction"/> mirroring
|
||||
/// what <c>WizardInteractionMapper</c> would produce for a Telegram
|
||||
/// photo message.
|
||||
/// </summary>
|
||||
public static WizardInteraction PhotoInteraction(
|
||||
string fileId, string ownerId = "100", int messageId = 1)
|
||||
{
|
||||
return new WizardInteraction(
|
||||
OwnerId: ownerId,
|
||||
Text: null,
|
||||
CallbackPayload: null,
|
||||
PhotoFileId: fileId,
|
||||
PhotoUrl: null,
|
||||
InteractionId: $"msg-{messageId}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a Telegram <see cref="Update"/> carrying a callback query.
|
||||
/// Used by router-level tests that exercise
|
||||
/// <c>UpdateRouter.RouteAsync</c> end-to-end.
|
||||
/// </summary>
|
||||
public static Update CallbackUpdate(string data, long ownerId = 100) => new()
|
||||
{
|
||||
CallbackQuery = new CallbackQuery
|
||||
@@ -57,6 +116,11 @@ internal static class WizardTestFakes
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build a Telegram <see cref="Update"/> carrying a text message.
|
||||
/// Used by router-level tests that exercise
|
||||
/// <c>UpdateRouter.RouteAsync</c> end-to-end.
|
||||
/// </summary>
|
||||
public static Update TextUpdate(string text, long ownerId = 100) => new()
|
||||
{
|
||||
Message = new Message
|
||||
@@ -69,9 +133,9 @@ internal static class WizardTestFakes
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records every call the wizard makes against the draft repository. Backed by
|
||||
/// an in-memory dictionary so tests can pre-seed an "active" draft for the
|
||||
/// wizard to mutate.
|
||||
/// Records every call the wizard makes against the draft repository.
|
||||
/// Backed by an in-memory dictionary so tests can pre-seed an "active"
|
||||
/// draft for the wizard to mutate.
|
||||
/// </summary>
|
||||
internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
||||
{
|
||||
@@ -85,13 +149,12 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
||||
|
||||
public void Seed(WizardDraft draft) => store[draft.Id] = draft;
|
||||
|
||||
public Task<WizardDraft?> GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
|
||||
public Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct)
|
||||
{
|
||||
foreach (var d in store.Values)
|
||||
{
|
||||
if (d.ChatId == chatId &&
|
||||
d.MessageThreadId == messageThreadId &&
|
||||
d.OwnerTelegramId == ownerTelegramId &&
|
||||
if (d.Platform == platform &&
|
||||
d.OwnerId == ownerId &&
|
||||
d.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
return Task.FromResult<WizardDraft?>(d);
|
||||
@@ -108,7 +171,8 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
||||
Id = draft.Id,
|
||||
ChatId = draft.ChatId,
|
||||
MessageThreadId = draft.MessageThreadId,
|
||||
OwnerTelegramId = draft.OwnerTelegramId,
|
||||
OwnerId = draft.OwnerId,
|
||||
Platform = draft.Platform,
|
||||
Step = draft.Step,
|
||||
PayloadJson = draft.PayloadJson,
|
||||
DraftMessageId = draft.DraftMessageId,
|
||||
@@ -136,11 +200,14 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records every call the wizard makes against the messenger. Default return
|
||||
/// values (empty clubs, message-id 1) match what the wizard expects to see
|
||||
/// in steady state.
|
||||
/// Records every call the wizard makes against the messenger. Default
|
||||
/// return values (empty clubs, message-id 99) match what the wizard
|
||||
/// expects to see in steady state. The recorded tuple shapes match
|
||||
/// the old <c>ITelegramWizardMessenger</c> recorders so existing test
|
||||
/// assertions (<c>edit.ChatId</c>, <c>edit.Text</c>, …) keep working
|
||||
/// after the refactor.
|
||||
/// </summary>
|
||||
internal sealed class FakeWizardMessenger : ITelegramWizardMessenger
|
||||
internal sealed class FakeWizardMessenger : IWizardMessenger
|
||||
{
|
||||
public List<(long ChatId, int? ThreadId, long MsgId, string Text)> Edits { get; } = new();
|
||||
|
||||
@@ -148,37 +215,44 @@ internal sealed class FakeWizardMessenger : ITelegramWizardMessenger
|
||||
|
||||
public List<(long ChatId, int? ThreadId, string Text)> Sends { get; } = new();
|
||||
|
||||
public List<(string OwnerId, IReadOnlyList<WizardAction> Actions)> EditActions { get; } = new();
|
||||
|
||||
public IReadOnlyList<WizardClubOption> Clubs { get; set; } = Array.Empty<WizardClubOption>();
|
||||
|
||||
public Task<long> EditMessageTextAsync(
|
||||
long chatId,
|
||||
int? messageThreadId,
|
||||
long messageId,
|
||||
public Task<string> EditDraftMessageAsync(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
InlineKeyboardMarkup keyboard,
|
||||
IReadOnlyList<WizardAction> keyboard,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Edits.Add((chatId, messageThreadId, messageId, text));
|
||||
return Task.FromResult(messageId);
|
||||
Edits.Add((
|
||||
long.TryParse(draft.ChatId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chatId) ? chatId : 0,
|
||||
int.TryParse(draft.MessageThreadId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var threadId) ? threadId : (int?)null,
|
||||
long.TryParse(draft.DraftMessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var msgId) ? msgId : 0,
|
||||
text));
|
||||
EditActions.Add((draft.OwnerId, keyboard));
|
||||
return Task.FromResult(draft.DraftMessageId ?? "0");
|
||||
}
|
||||
|
||||
public Task<long> SendGroupMessageAsync(
|
||||
long chatId,
|
||||
int? messageThreadId,
|
||||
public Task<string> SendDraftMessageAsync(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
InlineKeyboardMarkup keyboard,
|
||||
IReadOnlyList<WizardAction> keyboard,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Sends.Add((chatId, messageThreadId, text));
|
||||
return Task.FromResult(99L);
|
||||
Sends.Add((
|
||||
long.TryParse(draft.ChatId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chatId) ? chatId : 0,
|
||||
int.TryParse(draft.MessageThreadId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var threadId) ? threadId : (int?)null,
|
||||
text));
|
||||
return Task.FromResult("99");
|
||||
}
|
||||
|
||||
public Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct)
|
||||
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
|
||||
{
|
||||
AnsweredCallbacks.Add(callbackId);
|
||||
AnsweredCallbacks.Add(interactionId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct)
|
||||
public Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct)
|
||||
=> Task.FromResult(Clubs);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user