using System; using System.Collections.Generic; using System.Globalization; using System.Threading; using System.Threading.Tasks; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Microsoft.Extensions.Logging.Abstractions; using Telegram.Bot.Types; using Telegram.Bot.Types.ReplyMarkups; using WizardBot = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard; namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; /// /// 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. /// internal static class WizardTestFakes { public const string PlatformName = "Telegram"; public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger) { drafts = new FakeWizardDraftRepository(); messenger = new FakeWizardMessenger(); return new WizardBot(drafts, messenger, NullLogger.Instance); } public static WizardDraft NewDraft(string step, WizardPayload? payload = null, long ownerId = 100) => new() { Id = Guid.NewGuid(), ChatId = "42", MessageThreadId = null, OwnerId = ownerId.ToString(CultureInfo.InvariantCulture), Platform = PlatformName, 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), }; /// /// Build the platform-neutral the /// wizard now consumes. Pre-V112 callers passed /// Telegram.Bot.Types.Update directly; tests now build the /// neutral interaction via the same mapper the production code uses. /// 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); } /// /// Build a text-style mirroring what /// WizardInteractionMapper would produce for a Telegram text /// message. /// 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}"); } /// /// Build a photo-style mirroring /// what WizardInteractionMapper would produce for a Telegram /// photo message. /// 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}"); } /// /// Build a Telegram carrying a callback query. /// Used by router-level tests that exercise /// UpdateRouter.RouteAsync end-to-end. /// 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 }, }, }, }; /// /// Build a Telegram carrying a text message. /// Used by router-level tests that exercise /// UpdateRouter.RouteAsync end-to-end. /// 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" }, }, }; } /// /// 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. /// internal sealed class FakeWizardDraftRepository : IWizardDraftRepository { private readonly Dictionary store = new(); public List DeletedIds { get; } = new(); public List Upserts { get; } = new(); public int ExpiredDeleted { get; set; } public void Seed(WizardDraft draft) => store[draft.Id] = draft; public Task GetActiveAsync(string platform, string ownerId, CancellationToken ct) { foreach (var d in store.Values) { if (d.Platform == platform && d.OwnerId == ownerId && d.ExpiresAt > DateTimeOffset.UtcNow) { return Task.FromResult(d); } } return Task.FromResult(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, OwnerId = draft.OwnerId, Platform = draft.Platform, 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 DeleteExpiredAsync(CancellationToken ct) { var count = ExpiredDeleted; ExpiredDeleted = 0; return Task.FromResult(count); } } /// /// 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 ITelegramWizardMessenger recorders so existing test /// assertions (edit.ChatId, edit.Text, …) keep working /// after the refactor. /// internal sealed class FakeWizardMessenger : IWizardMessenger { public List<(long ChatId, int? ThreadId, long MsgId, string Text)> Edits { get; } = new(); public List AnsweredCallbacks { get; } = new(); public List<(long ChatId, int? ThreadId, string Text)> Sends { get; } = new(); public List<(string OwnerId, IReadOnlyList Actions)> EditActions { get; } = new(); public IReadOnlyList Clubs { get; set; } = Array.Empty(); public Task EditDraftMessageAsync( WizardDraft draft, string text, IReadOnlyList keyboard, CancellationToken ct) { 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 SendDraftMessageAsync( WizardDraft draft, string text, IReadOnlyList keyboard, CancellationToken ct) { 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 AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct) { AnsweredCallbacks.Add(interactionId); return Task.CompletedTask; } public Task> GetOwnerClubsAsync(string ownerId, CancellationToken ct) => Task.FromResult(Clubs); }