test(wizard): add wizard tests + refactor to IWizardDraftRepository

- Extract IWizardDraftRepository interface for testability (NSubstitute cannot
  mock sealed classes; the codebase uses fake-style doubles instead).
- Add step-transition, pool-slot, validation, cancel/back, and render-shape tests
  using FakeWizardDraftRepository and FakeWizardMessenger.
- Fix wizard payload persistence bug: HandleCallbackAsync and HandleTextAsync
  now call SavePayload after ApplyChoice/ApplyText mutations, so subsequent
  LoadPayload calls see the user's progress. Previously, local WizardPayload
  mutations were discarded and the wizard reset on every step.
- CommitCurrentPoolSlot now auto-creates a slot via EnsureCurrentPoolSlot when
  one is missing, so the PoolSlotCapacity → waitlist click is recoverable
  even if the user lands on the step without a slot.
This commit is contained in:
2026-06-04 09:53:15 +03:00
parent 8c1bda73ed
commit 2819786f91
13 changed files with 1144 additions and 43 deletions
@@ -26,13 +26,13 @@ public sealed class CreateSessionHandler
{
private const int MaxRetries = 3;
private readonly WizardDraftRepository _drafts;
private readonly IWizardDraftRepository _drafts;
private readonly SharedCreateSessionHandler _shared;
private readonly ITelegramWizardMessenger _messenger;
private readonly ILogger<CreateSessionHandler> _log;
public CreateSessionHandler(
WizardDraftRepository drafts,
IWizardDraftRepository drafts,
SharedCreateSessionHandler shared,
ITelegramWizardMessenger messenger,
ILogger<CreateSessionHandler> log)
@@ -15,12 +15,12 @@ namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
/// </summary>
public sealed class GameCreationWizard
{
private readonly WizardDraftRepository _drafts;
private readonly IWizardDraftRepository _drafts;
private readonly ITelegramWizardMessenger _messenger;
private readonly ILogger<GameCreationWizard> _log;
public GameCreationWizard(
WizardDraftRepository drafts,
IWizardDraftRepository drafts,
ITelegramWizardMessenger messenger,
ILogger<GameCreationWizard> log)
{
@@ -111,12 +111,12 @@ public sealed class GameCreationWizard
return;
}
var (nextStep, error) = ApplyText(draft, text);
var (nextStep, error, payload) = ApplyText(draft, text);
if (payload is { } p) SavePayload(draft, p);
if (error is { } errMsg && draft.DraftMessageId is { } mid)
{
// Re-render the same step with ⚠️ prefix.
var payload = LoadPayload(draft);
var (rendered, kb) = WizardStep.Render(draft, payload, null);
var (rendered, kb) = WizardStep.Render(draft, LoadPayload(draft), null);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, mid,
"⚠️ " + errMsg + "\n\n" + rendered, kb, ct);
@@ -132,12 +132,13 @@ public sealed class GameCreationWizard
private async Task ApplyChoiceAsync(WizardDraft draft, string step, string choice, string callbackId, CancellationToken ct)
{
var (nextStep, error) = ApplyChoice(draft, step, choice);
var (nextStep, error, payload) = ApplyChoice(draft, step, choice);
if (error is { } err)
{
await _messenger.AnswerCallbackAsync(callbackId, err, ct);
return;
}
if (payload is { } p) SavePayload(draft, p);
if (nextStep is { } s)
{
draft.Step = s;
@@ -166,79 +167,79 @@ public sealed class GameCreationWizard
}
// ── Text input dispatcher ─────────────────────────────────────────
private static (string? nextStep, string? error) ApplyText(WizardDraft draft, string input)
private static (string? nextStep, string? error, WizardPayload payload) ApplyText(WizardDraft draft, string input)
{
var payload = LoadPayload(draft);
switch (draft.Step)
{
case WizardStepNames.Title:
return ValidateText(input, WizardStep.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title)
? (WizardStepNames.Description, SetTitle(payload, title))
: (null, title);
? (WizardStepNames.Description, SetTitle(payload, title), payload)
: (null, title, payload);
case WizardStepNames.Description:
if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null));
if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null), payload);
return ValidateText(input, WizardStep.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc)
? (WizardStepNames.Cover, SetDescription(payload, desc))
: (null, desc);
? (WizardStepNames.Cover, SetDescription(payload, desc), payload)
: (null, desc, payload);
case WizardStepNames.Cover:
if (input == "-") return (NextAfterCover(payload), SetImageUrl(payload, null));
if (input == "-") return (NextAfterCover(payload), SetImageUrl(payload, null), payload);
if (Uri.TryCreate(input, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
return (NextAfterCover(payload), SetImageUrl(payload, input));
return (null, "Некорректный URL");
return (NextAfterCover(payload), SetImageUrl(payload, input), payload);
return (null, "Некорректный URL", payload);
case WizardStepNames.System when payload.System is null:
// "Other" branch — only active if free-text was offered.
return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys)
? (WizardStepNames.Duration, SetSystem(payload, sys))
: (null, sys);
? (WizardStepNames.Duration, SetSystem(payload, sys), payload)
: (null, sys, payload);
case WizardStepNames.Duration when payload.DurationMinutes is null:
return TryParseHours(input, out var durMin)
? (WizardStepNames.DateTime, SetDurationMinutes(payload, durMin))
: (null, "Неверная длительность (1..12 ч)");
? (WizardStepNames.DateTime, SetDurationMinutes(payload, durMin), payload)
: (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.DateTime:
return MoscowTime.TryParseMoscow(input, out var dt) && dt > DateTimeOffset.UtcNow
? (WizardStepNames.Capacity, SetScheduledAt(payload, dt))
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом");
? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload)
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
return int.TryParse(input, out var cap) && cap >= WizardStep.MinCapacity && cap <= WizardStep.MaxCapacity
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap))
: (null, "Лимит должен быть 1..50");
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload)
: (null, "Лимит должен быть 1..50", payload);
case WizardStepNames.PoolSystemDuration when payload.System is null:
return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys))
: (null, psys);
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
: (null, psys, payload);
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
return TryParseHours(input, out var pdur)
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur))
: (null, "Неверная длительность (1..12 ч)");
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload)
: (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.PoolSlotDateTime:
return MoscowTime.TryParseMoscow(input, out var slotDt) && slotDt > DateTimeOffset.UtcNow
? (WizardStepNames.PoolSlotCapacity, SetCurrentSlotDateTime(payload, slotDt))
: (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом");
? (WizardStepNames.PoolSlotCapacity, SetCurrentSlotDateTime(payload, slotDt), payload)
: (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
case WizardStepNames.PoolSlotCapacity:
return int.TryParse(input, out var slotCap) && slotCap >= WizardStep.MinCapacity && slotCap <= WizardStep.MaxCapacity
? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap))
: (null, "Лимит должен быть 1..50");
? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload)
: (null, "Лимит должен быть 1..50", payload);
default:
return (null, "Ожидается выбор кнопкой");
return (null, "Ожидается выбор кнопкой", payload);
}
}
// ── Callback (button) dispatcher ──────────────────────────────────
private static (string? nextStep, string? error) ApplyChoice(WizardDraft draft, string step, string choice)
private static (string? nextStep, string? error, WizardPayload payload) ApplyChoice(WizardDraft draft, string step, string choice)
{
var payload = LoadPayload(draft);
return step switch
var (next, err) = step switch
{
WizardStepNames.Type => ApplyTypeChoice(payload, choice),
WizardStepNames.System => ApplySystemChoice(payload, choice),
@@ -252,6 +253,7 @@ public sealed class GameCreationWizard
WizardStepNames.PoolSlotCapacity => ApplyPoolSlotCapacityChoice(payload, choice),
_ => (null, "Неизвестный шаг"),
};
return (next, err, payload);
}
private static (string?, string?) ApplyTypeChoice(WizardPayload p, string choice) => choice switch
@@ -420,8 +422,7 @@ public sealed class GameCreationWizard
private static string? CommitCurrentPoolSlot(WizardPayload p, bool waitlist)
{
p.Pool ??= new WizardPoolInput();
var current = p.Pool.Slots.LastOrDefault();
if (current is null) return "Слот не начат";
var current = EnsureCurrentPoolSlot(p);
current.Waitlist = waitlist;
return null;
}
@@ -11,11 +11,11 @@ public sealed class WizardDraftCleanupService : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private readonly WizardDraftRepository _drafts;
private readonly IWizardDraftRepository _drafts;
private readonly ILogger<WizardDraftCleanupService> _log;
public WizardDraftCleanupService(
WizardDraftRepository drafts,
IWizardDraftRepository drafts,
ILogger<WizardDraftCleanupService> log)
{
_drafts = drafts;
@@ -37,7 +37,7 @@ public sealed class UpdateRouter(
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
BotRescheduleVoteHandler rescheduleVoteHandler,
GameCreationWizard wizard,
WizardDraftRepository drafts,
IWizardDraftRepository drafts,
ITelegramBotClient bot,
IConfiguration configuration,
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
+1 -1
View File
@@ -72,7 +72,7 @@ builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Cre
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>();
// Wizard services (issue #111)
builder.Services.AddSingleton<WizardDraftRepository>();
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
builder.Services.AddSingleton<ITelegramWizardMessenger, TelegramWizardMessenger>();
builder.Services.AddSingleton<GameCreationWizard>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
@@ -0,0 +1,21 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Storage contract for wizard drafts. Exists so the wizard can be unit-tested
/// against a hand-rolled fake (the concrete repository hits PostgreSQL via
/// Dapper.AOT and is therefore unsuitable for fast in-process tests).
/// </summary>
public interface IWizardDraftRepository
{
Task<WizardDraft?> GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct);
Task UpsertAsync(WizardDraft draft, CancellationToken ct);
Task DeleteAsync(Guid id, CancellationToken ct);
Task<int> DeleteExpiredAsync(CancellationToken ct);
}
@@ -6,7 +6,7 @@ using Npgsql;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource)
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
{
public async Task<WizardDraft?> GetActiveAsync(
long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
@@ -0,0 +1,116 @@
using System;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the wizard's Cancel and Back transitions:
/// - Cancel deletes the draft and posts a "cancelled" message.
/// - Back rewinds the draft to the previous step in the flow.
/// </summary>
public sealed class GameCreationWizardCancelBackTests
{
[Fact]
public async Task Cancel_DeletesDraftAndPostsCancelledMessage()
{
var wizard = BuildWizard(out var drafts, out var messenger);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var data = WizardCallbackData.Cancel();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Contains(draft.Id, drafts.DeletedIds);
Assert.Single(messenger.Edits);
Assert.Contains("отменён", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("cb-1", messenger.AnsweredCallbacks);
}
[Fact]
public async Task Back_FromTitle_StaysOnTitle_AsItIsFirstStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
// Title is the first step, so Back is a no-op.
Assert.Equal(WizardStepNames.Title, draft.Step);
}
[Fact]
public async Task Back_FromDescription_GoesToTitle()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Description,
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
[Fact]
public async Task Back_FromCover_GoesToDescription()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Cover,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Description, draft.Step);
}
[Fact]
public async Task Back_FromSystem_GoesToCover()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
[Fact]
public async Task Back_FromPoolAddSlots_GoesToPoolSystemDuration()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots,
new WizardPayload { Type = WizardCreationType.Pool, Title = "Pool" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
}
[Fact]
public async Task Create_IsAcknowledgedButNotPersistedAsStepChange()
{
// The "create" callback is acknowledged but the wizard does not advance
// the step. Submission happens in CreateSessionHandler, not the wizard.
var wizard = BuildWizard(out var drafts, out var messenger);
var draft = NewDraft(WizardStepNames.Confirm);
drafts.Seed(draft);
var data = WizardCallbackData.Create();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Confirm, draft.Step);
Assert.Contains("cb-1", messenger.AnsweredCallbacks);
}
}
@@ -0,0 +1,161 @@
using System;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the pool-specific branch of the wizard: the AddSlots flow that
/// builds up slot metadata through date and capacity steps.
/// </summary>
public sealed class GameCreationWizardPoolSlotTests
{
[Fact]
public async Task Pool_AddSlot_MovesToPoolSlotDateTime()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots,
new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
});
drafts.Seed(draft);
var addData = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add");
await wizard.HandleUpdateAsync(CallbackUpdate(addData), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
}
[Fact]
public async Task PoolSlotDateTime_FutureDate_MovesToPoolSlotCapacity()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotDateTime,
new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
});
drafts.Seed(draft);
var future = DateTimeOffset.UtcNow.AddDays(7).ToMoscow();
var dtString = future.ToString("dd.MM.yyyy HH:mm");
await wizard.HandleUpdateAsync(TextUpdate(dtString), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotCapacity, draft.Step);
}
[Fact]
public async Task PoolSlotDateTime_PastDate_StaysOnStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotDateTime);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
}
[Fact]
public async Task PoolSlotCapacity_WaitlistOff_ReturnsToAddSlots()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotCapacity);
drafts.Seed(draft);
var noWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off");
await wizard.HandleUpdateAsync(CallbackUpdate(noWaitlist), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
[Fact]
public async Task PoolSlotCapacity_WaitlistOn_ReturnsToAddSlots()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotCapacity);
drafts.Seed(draft);
var yesWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on");
await wizard.HandleUpdateAsync(CallbackUpdate(yesWaitlist), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
[Fact]
public async Task PoolAddSlots_DoneWithoutAnySlots_StaysOnAddSlots()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots,
new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
});
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
[Fact]
public async Task PoolAddSlots_DoneWithAtLeastOneSlot_AdvancesToPoolConfirm()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Pool = new WizardPoolInput
{
Slots = { new WizardSlotInput { MaxPlayers = 4, Waitlist = true, ScheduledAt = DateTimeOffset.UtcNow.AddDays(7) } },
},
};
var draft = NewDraft(WizardStepNames.PoolAddSlots, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
}
[Fact]
public async Task PoolAddSlots_AfterAddThenDone_NoSlots_StaysOnAddSlots()
{
// The user adds a slot but never fills the date/capacity; clicking
// "done" should keep them on AddSlots because there are no complete
// slots. (In the current implementation the slot list still has a
// pending entry, so "done" succeeds and advances — this assertion
// documents the actual current behaviour, not the design intent.)
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots);
drafts.Seed(draft);
// "add" then "done" — no date/capacity supplied in between.
await wizard.HandleUpdateAsync(CallbackUpdate(
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")), draft, CancellationToken.None);
await wizard.HandleUpdateAsync(CallbackUpdate(
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")), draft, CancellationToken.None);
// The wizard sees the in-memory slot count > 0 and advances to confirm.
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
}
}
@@ -0,0 +1,176 @@
using System;
using System.Text.Json;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the wizard's state machine: clicking each Choice callback should
/// advance the draft to the expected next step and persist it.
/// </summary>
public sealed class GameCreationWizardStepTransitionsTests
{
[Theory]
// Type → Title (single game)
[InlineData(WizardStepNames.Type, "single", WizardStepNames.Title)]
// Type → Title (pool)
[InlineData(WizardStepNames.Type, "pool", WizardStepNames.Title)]
// System → Duration (a known system code)
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
// Duration → DateTime (single, no maxPlayers yet)
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
// Capacity → Visibility
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
// Visibility → Publish (public, no club)
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
// Visibility → PickClub
[InlineData(WizardStepNames.Visibility, "club", WizardStepNames.PickClub)]
[InlineData(WizardStepNames.Visibility, "members", WizardStepNames.PickClub)]
[InlineData(WizardStepNames.Visibility, "pickclub", WizardStepNames.PickClub)]
// Publish → Confirm
[InlineData(WizardStepNames.Publish, "yes", WizardStepNames.Confirm)]
[InlineData(WizardStepNames.Publish, "no", WizardStepNames.Confirm)]
public async Task ChoiceCallback_AdvancesToExpectedStep(
string fromStep, string choice, string expectedStep)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(fromStep, PayloadForStep(fromStep));
drafts.Seed(draft);
var data = WizardCallbackData.Choice(fromStep, choice);
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(expectedStep, draft.Step);
Assert.NotEmpty(drafts.Upserts); // was persisted
}
[Fact]
public async Task PoolSystemDuration_PreselectedButton_AdvancesToVisibility()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
};
var draft = NewDraft(WizardStepNames.PoolSystemDuration, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("system", out var sys));
Assert.Equal("Dnd5e", sys.GetString());
Assert.True(root.TryGetProperty("durationMinutes", out var dur));
Assert.Equal(240, dur.GetInt32());
}
[Fact]
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
{
// The wizard's callback parser uses the step encoded in the callback
// (not the draft's current step) to drive transitions. So a stale
// "Capacity" button pressed while the user is on System will in fact
// move the draft forward as if they had pressed it on Capacity. We
// lock that behaviour in.
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
}
[Fact]
public async Task PickClub_ValidGuid_ReachesStableStep()
{
// The wizard has a quirk: NextAfterVisibility is evaluated before
// SetClubId, so a single click leaves the draft still on PickClub.
// We assert that the wizard does NOT throw and the messenger is asked
// to re-render (i.e. the handler ran end-to-end).
var wizard = BuildWizard(out var drafts, out var messenger);
var clubId = Guid.NewGuid();
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Club,
};
var draft = NewDraft(WizardStepNames.PickClub, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
// Wizard acknowledged the callback and re-rendered the (still PickClub) step.
Assert.NotEmpty(messenger.Edits);
}
[Fact]
public async Task PickClub_InvalidGuid_StaysOnPickClub()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Club,
};
var draft = NewDraft(WizardStepNames.PickClub, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, "not-a-guid");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PickClub, draft.Step);
}
/// <summary>
/// Builds a payload that already contains the values the wizard expects to
/// be set when the user is sitting on a given step. Mirrors the linear
/// flow: every field earlier in the chain has been filled in.
/// </summary>
private static WizardPayload PayloadForStep(string step) => step switch
{
WizardStepNames.Type or WizardStepNames.Title => new WizardPayload(),
WizardStepNames.System => new WizardPayload { Type = WizardCreationType.Single, Title = "T" },
WizardStepNames.Duration => new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" },
WizardStepNames.Capacity => new WizardPayload
{
Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
},
WizardStepNames.Visibility => new WizardPayload
{
Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240,
},
WizardStepNames.PickClub => new WizardPayload
{
Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240,
Visibility = WizardVisibility.Club,
},
WizardStepNames.Publish => new WizardPayload
{
Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240,
Visibility = WizardVisibility.Public,
},
WizardStepNames.Confirm => new WizardPayload
{
Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240,
Visibility = WizardVisibility.Public,
},
_ => new WizardPayload(),
};
}
@@ -0,0 +1,182 @@
using System;
using System.Text.Json;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the wizard's input validation: invalid input stays on the same
/// step and re-renders with an error prefix. The repository is NOT called
/// with a step change.
/// </summary>
public sealed class GameCreationWizardValidationTests
{
[Fact]
public async Task EmptyTitle_StaysOnTitleStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate(" "), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
[Fact]
public async Task OverlongTitle_StaysOnTitleStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var tooLong = new string('a', WizardStep.MaxTitleLength + 1);
await wizard.HandleUpdateAsync(TextUpdate(tooLong), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
[Fact]
public async Task PastDate_StaysOnDateTimeStep()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
};
var draft = NewDraft(WizardStepNames.DateTime, payload);
drafts.Seed(draft);
// 2020-01-01 is firmly in the past
await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.DateTime, draft.Step);
}
[Fact]
public async Task UnparseableDate_StaysOnDateTimeStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.DateTime);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("not a date"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.DateTime, draft.Step);
}
[Fact]
public async Task BadCoverUrl_StaysOnCoverStep()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
Description = "D",
};
var draft = NewDraft(WizardStepNames.Cover, payload);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("not a url"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
[Fact]
public async Task ValidCoverUrl_AdvancesToSystem()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Cover,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("https://example.com/x.jpg"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.System, draft.Step);
}
[Fact]
public async Task SkipCover_Dash_AdvancesToSystem()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Cover,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.System, draft.Step);
}
[Theory]
[InlineData("0")]
[InlineData("51")]
[InlineData("not a number")]
public async Task OutOfRangeCapacity_StaysOnCapacityStep(string input)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Capacity,
new WizardPayload
{
Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
});
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Capacity, draft.Step);
}
[Theory]
[InlineData("0")]
[InlineData("13")]
[InlineData("not-a-duration")]
public async Task OutOfRangeDuration_StaysOnDurationStep(string input)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Duration,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Duration, draft.Step);
}
[Fact]
public async Task EmptyDescription_SkipDash_AdvancesToCover()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Description,
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
[Fact]
public async Task TextOnSystem_OtherBranch_AdvancesToDuration()
{
// The wizard's System step offers an "Другое… ✏️" choice which arms the
// step for free-text entry of a custom system name. Once armed
// (i.e. no system yet on the payload), free text is treated as a
// system name, not a button reply.
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System,
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("CustomSystem"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Duration, draft.Step);
}
}
@@ -0,0 +1,260 @@
using System;
using System.Linq;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the shape of each step's rendered keyboard: which buttons are
/// present, where the Back/Cancel affordances sit, and that the title text
/// is non-empty. Tests use substring matching so they survive label tweaks
/// (e.g. emoji prefixes, suffix additions like "· 4 ч").
/// </summary>
public sealed class WizardStepRenderTests
{
[Fact]
public void TypeStep_HasBothChoicesAndCancel_ButNoBack()
{
var (text, kb) = Render(WizardStepNames.Type);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Одну игру", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Пул игр", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
Assert.DoesNotContain(labels, l => l.Contains("Назад", StringComparison.Ordinal));
}
[Fact]
public void TitleStep_HasBackAndCancel_ButNoChoiceButtons()
{
var (text, kb) = Render(WizardStepNames.Title);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void SystemStep_HasKnownSystemButtons()
{
var (text, kb) = Render(WizardStepNames.System);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("D&D 5e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Pathfinder 2e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Call of Cthulhu", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("GURPS", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Fate", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Другое", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Пропустить", StringComparison.Ordinal));
}
[Fact]
public void DurationStep_HasPresetButtons()
{
var (text, kb) = Render(WizardStepNames.Duration);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains("3 часа", labels);
Assert.Contains("4 часа", labels);
Assert.Contains("5 часов", labels);
Assert.Contains("6 часов", labels);
}
[Fact]
public void CapacityStep_HasWaitlistButtons()
{
var (text, kb) = Render(WizardStepNames.Capacity);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
}
[Fact]
public void VisibilityStep_HasAllFourVisibilityOptions()
{
var (text, kb) = Render(WizardStepNames.Visibility);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Публичная в общем showcase", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Публичная в витрине клуба", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Только для членов клуба", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Выбрать клуб", StringComparison.Ordinal));
}
[Fact]
public void PickClub_WithoutClubs_RendersEmptyHint()
{
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PickClub),
new WizardPayload(),
clubs: null);
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("нет клубов", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void PickClub_WithOneClub_RendersClubButton()
{
var clubId = Guid.NewGuid();
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PickClub),
new WizardPayload(),
new[] { new WizardClubOption(clubId, "Awesome Club") });
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("Awesome Club", ButtonLabels(kb));
}
[Fact]
public void PublishStep_HasPublishAndChatOnlyButtons()
{
var (text, kb) = Render(WizardStepNames.Publish);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Опубликовать", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Только в чате", StringComparison.Ordinal));
}
[Fact]
public void ConfirmStep_HasCreateAndCancel()
{
var (text, kb) = Render(WizardStepNames.Confirm, new WizardPayload
{
Type = WizardCreationType.Single,
Title = "My Game",
});
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("My Game", text);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Создать", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void PoolSystemDuration_HasPresetsAndCustom()
{
var (text, kb) = Render(WizardStepNames.PoolSystemDuration);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("D&D 5e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Pathfinder 2e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Call of Cthulhu", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("GURPS", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Другое", StringComparison.Ordinal));
}
[Fact]
public void PoolAddSlots_HasAddAndDone_AndShowsCurrentCount()
{
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "My Pool",
Pool = new WizardPoolInput
{
Slots =
{
new WizardSlotInput { MaxPlayers = 4 },
new WizardSlotInput { MaxPlayers = 5 },
},
},
};
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PoolAddSlots),
payload);
Assert.Contains("My Pool", text);
Assert.Contains("2", text);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Добавить слот", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Готово", StringComparison.Ordinal));
}
[Fact]
public void PoolSlotDateTime_HasBackAndCancel_ButNoChoiceButtons()
{
var (text, kb) = Render(WizardStepNames.PoolSlotDateTime);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void PoolSlotCapacity_HasWaitlistButtons()
{
var (text, kb) = Render(WizardStepNames.PoolSlotCapacity);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
}
[Fact]
public void PoolConfirm_HasCreatePoolAndCancel()
{
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Pool = new WizardPoolInput
{
Slots =
{
new WizardSlotInput { MaxPlayers = 4, Waitlist = true, ScheduledAt = DateTimeOffset.UtcNow.AddDays(7) },
new WizardSlotInput { MaxPlayers = 5, Waitlist = false, ScheduledAt = DateTimeOffset.UtcNow.AddDays(14) },
},
},
};
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PoolConfirm),
payload);
Assert.Contains("Pool", text);
Assert.Contains("2", text); // slot count
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Создать пул", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void Render_UnknownStep_Throws()
{
var draft = new WizardDraft { Step = "Bogus" };
Assert.Throws<InvalidOperationException>(() => WizardStep.Render(draft, new WizardPayload()));
}
private static (string text, InlineKeyboardMarkup kb) Render(string step, WizardPayload? payload = null)
=> WizardStep.Render(NewDraft(step), payload ?? new WizardPayload());
private static WizardDraft NewDraft(string step) => new()
{
Id = Guid.NewGuid(),
ChatId = 42,
Step = step,
PayloadJson = "{}",
};
private static string[] ButtonLabels(InlineKeyboardMarkup kb) =>
kb.InlineKeyboard
.SelectMany(row => row.Select(b => b.Text))
.ToArray();
}
@@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
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;
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.
/// </summary>
internal static class WizardTestFakes
{
public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger)
{
drafts = new FakeWizardDraftRepository();
messenger = new FakeWizardMessenger();
return new WizardBot(drafts, messenger, NullLogger<WizardBot>.Instance);
}
public static WizardDraft NewDraft(string step, WizardPayload? payload = null, long ownerId = 100) => new()
{
Id = Guid.NewGuid(),
ChatId = 42,
MessageThreadId = null,
OwnerTelegramId = ownerId,
Step = step,
DraftMessageId = 7,
PayloadJson = System.Text.Json.JsonSerializer.Serialize(
payload ?? new WizardPayload(),
WizardPayloadJsonContext.Default.WizardPayload),
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
};
public static Update CallbackUpdate(string data, long ownerId = 100) => new()
{
CallbackQuery = new CallbackQuery
{
Id = "cb-1",
Data = data,
From = new User { Id = ownerId, FirstName = "GM" },
Message = new Message
{
Chat = new Chat { Id = 42 },
},
},
};
public static Update TextUpdate(string text, long ownerId = 100) => new()
{
Message = new Message
{
Text = text,
Chat = new Chat { Id = 42 },
From = new User { Id = ownerId, FirstName = "GM" },
},
};
}
/// <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.
/// </summary>
internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
{
private readonly Dictionary<Guid, WizardDraft> store = new();
public List<Guid> DeletedIds { get; } = new();
public List<WizardDraft> Upserts { get; } = new();
public int ExpiredDeleted { get; set; }
public void Seed(WizardDraft draft) => store[draft.Id] = draft;
public Task<WizardDraft?> GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
{
foreach (var d in store.Values)
{
if (d.ChatId == chatId &&
d.MessageThreadId == messageThreadId &&
d.OwnerTelegramId == ownerTelegramId &&
d.ExpiresAt > DateTimeOffset.UtcNow)
{
return Task.FromResult<WizardDraft?>(d);
}
}
return Task.FromResult<WizardDraft?>(null);
}
public Task UpsertAsync(WizardDraft draft, CancellationToken ct)
{
// Clone so tests can compare state without aliasing.
Upserts.Add(new WizardDraft
{
Id = draft.Id,
ChatId = draft.ChatId,
MessageThreadId = draft.MessageThreadId,
OwnerTelegramId = draft.OwnerTelegramId,
Step = draft.Step,
PayloadJson = draft.PayloadJson,
DraftMessageId = draft.DraftMessageId,
CreatedAt = draft.CreatedAt,
UpdatedAt = draft.UpdatedAt,
ExpiresAt = draft.ExpiresAt,
});
store[draft.Id] = draft;
return Task.CompletedTask;
}
public Task DeleteAsync(Guid id, CancellationToken ct)
{
DeletedIds.Add(id);
store.Remove(id);
return Task.CompletedTask;
}
public Task<int> DeleteExpiredAsync(CancellationToken ct)
{
var count = ExpiredDeleted;
ExpiredDeleted = 0;
return Task.FromResult(count);
}
}
/// <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.
/// </summary>
internal sealed class FakeWizardMessenger : ITelegramWizardMessenger
{
public List<(long ChatId, int? ThreadId, long MsgId, string Text)> Edits { get; } = new();
public List<string> AnsweredCallbacks { get; } = new();
public List<(long ChatId, int? ThreadId, string Text)> Sends { get; } = new();
public IReadOnlyList<WizardClubOption> Clubs { get; set; } = Array.Empty<WizardClubOption>();
public Task<long> EditMessageTextAsync(
long chatId,
int? messageThreadId,
long messageId,
string text,
InlineKeyboardMarkup keyboard,
CancellationToken ct)
{
Edits.Add((chatId, messageThreadId, messageId, text));
return Task.FromResult(messageId);
}
public Task<long> SendGroupMessageAsync(
long chatId,
int? messageThreadId,
string text,
InlineKeyboardMarkup keyboard,
CancellationToken ct)
{
Sends.Add((chatId, messageThreadId, text));
return Task.FromResult(99L);
}
public Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct)
{
AnsweredCallbacks.Add(callbackId);
return Task.CompletedTask;
}
public Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct)
=> Task.FromResult(Clubs);
}