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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user