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:
2026-06-05 16:23:20 +03:00
parent 71080aeab6
commit 8f0f2ef7e7
33 changed files with 1308 additions and 534 deletions
@@ -2,7 +2,6 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
@@ -39,7 +38,7 @@ public sealed class CreateSessionHandlerSubmitMissingFieldsTests
// The wizard message is edited to surface the missing-field error.
Assert.Single(messenger.Edits);
var edit = messenger.Edits[0];
Assert.Equal(draft.ChatId, edit.ChatId);
Assert.Equal(long.Parse(draft.ChatId, System.Globalization.CultureInfo.InvariantCulture), edit.ChatId);
Assert.Contains("Не заполнены", edit.Text, StringComparison.OrdinalIgnoreCase);
}
@@ -2,7 +2,6 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
@@ -1,5 +1,4 @@
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;
@@ -20,7 +19,7 @@ public sealed class GameCreationWizardCancelBackTests
drafts.Seed(draft);
var data = WizardCallbackData.Cancel();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Contains(draft.Id, drafts.DeletedIds);
Assert.Single(messenger.Edits);
@@ -36,7 +35,7 @@ public sealed class GameCreationWizardCancelBackTests
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
// Title is the first step, so Back is a no-op.
Assert.Equal(WizardStepNames.Title, draft.Step);
@@ -51,7 +50,7 @@ public sealed class GameCreationWizardCancelBackTests
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
@@ -65,7 +64,7 @@ public sealed class GameCreationWizardCancelBackTests
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Description, draft.Step);
}
@@ -79,7 +78,7 @@ public sealed class GameCreationWizardCancelBackTests
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
@@ -93,7 +92,7 @@ public sealed class GameCreationWizardCancelBackTests
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
}
@@ -108,7 +107,7 @@ public sealed class GameCreationWizardCancelBackTests
drafts.Seed(draft);
var data = WizardCallbackData.Create();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Confirm, draft.Step);
Assert.Contains("cb-1", messenger.AnsweredCallbacks);
@@ -1,5 +1,4 @@
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;
@@ -7,8 +6,8 @@ using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTest
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.
/// 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
{
@@ -28,7 +27,7 @@ public sealed class GameCreationWizardPoolSlotTests
drafts.Seed(draft);
var addData = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add");
await wizard.HandleUpdateAsync(CallbackUpdate(addData), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(addData, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
}
@@ -49,7 +48,7 @@ public sealed class GameCreationWizardPoolSlotTests
var future = DateTimeOffset.UtcNow.AddDays(7).ToMoscow();
var dtString = future.ToString("dd.MM.yyyy HH:mm");
await wizard.HandleUpdateAsync(TextUpdate(dtString), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(TextInteraction(dtString, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotCapacity, draft.Step);
}
@@ -61,7 +60,7 @@ public sealed class GameCreationWizardPoolSlotTests
var draft = NewDraft(WizardStepNames.PoolSlotDateTime);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
}
@@ -74,7 +73,7 @@ public sealed class GameCreationWizardPoolSlotTests
drafts.Seed(draft);
var noWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off");
await wizard.HandleUpdateAsync(CallbackUpdate(noWaitlist), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(noWaitlist, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
@@ -87,7 +86,7 @@ public sealed class GameCreationWizardPoolSlotTests
drafts.Seed(draft);
var yesWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on");
await wizard.HandleUpdateAsync(CallbackUpdate(yesWaitlist), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(yesWaitlist, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
@@ -107,7 +106,7 @@ public sealed class GameCreationWizardPoolSlotTests
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
@@ -132,7 +131,7 @@ public sealed class GameCreationWizardPoolSlotTests
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
}
@@ -150,10 +149,10 @@ public sealed class GameCreationWizardPoolSlotTests
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);
await wizard.HandleInteractionAsync(CallbackInteraction(
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"), ownerId: draft.OwnerId), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"), ownerId: draft.OwnerId), draft, CancellationToken.None);
// The wizard sees the in-memory slot count > 0 and advances to confirm.
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
@@ -1,6 +1,5 @@
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;
@@ -41,7 +40,7 @@ public sealed class GameCreationWizardStepTransitionsTests
drafts.Seed(draft);
var data = WizardCallbackData.Choice(fromStep, choice);
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(expectedStep, draft.Step);
Assert.NotEmpty(drafts.Upserts); // was persisted
@@ -60,7 +59,7 @@ public sealed class GameCreationWizardStepTransitionsTests
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
@@ -84,7 +83,7 @@ public sealed class GameCreationWizardStepTransitionsTests
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
}
@@ -110,7 +109,7 @@ public sealed class GameCreationWizardStepTransitionsTests
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
// Wizard acknowledged the callback and re-rendered the (still PickClub) step.
Assert.NotEmpty(messenger.Edits);
@@ -132,7 +131,7 @@ public sealed class GameCreationWizardStepTransitionsTests
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, "not-a-guid");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PickClub, draft.Step);
}
@@ -1,6 +1,5 @@
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;
@@ -20,7 +19,7 @@ public sealed class GameCreationWizardValidationTests
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate(" "), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(TextInteraction(" ", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
@@ -32,8 +31,8 @@ public sealed class GameCreationWizardValidationTests
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var tooLong = new string('a', WizardStep.MaxTitleLength + 1);
await wizard.HandleUpdateAsync(TextUpdate(tooLong), draft, CancellationToken.None);
var tooLong = new string('a', WizardStepLimits.MaxTitleLength + 1);
await wizard.HandleInteractionAsync(TextInteraction(tooLong, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
@@ -53,7 +52,7 @@ public sealed class GameCreationWizardValidationTests
drafts.Seed(draft);
// 2020-01-01 is firmly in the past
await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.DateTime, draft.Step);
}
@@ -65,7 +64,7 @@ public sealed class GameCreationWizardValidationTests
var draft = NewDraft(WizardStepNames.DateTime);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("not a date"), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(TextInteraction("not a date", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.DateTime, draft.Step);
}
@@ -83,7 +82,7 @@ public sealed class GameCreationWizardValidationTests
var draft = NewDraft(WizardStepNames.Cover, payload);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("not a url"), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(TextInteraction("not a url", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
@@ -96,7 +95,7 @@ public sealed class GameCreationWizardValidationTests
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("https://example.com/x.jpg"), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(TextInteraction("https://example.com/x.jpg", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.System, draft.Step);
}
@@ -109,7 +108,7 @@ public sealed class GameCreationWizardValidationTests
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.System, draft.Step);
}
@@ -132,7 +131,7 @@ public sealed class GameCreationWizardValidationTests
});
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Capacity, draft.Step);
}
@@ -148,7 +147,7 @@ public sealed class GameCreationWizardValidationTests
new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Duration, draft.Step);
}
@@ -161,7 +160,7 @@ public sealed class GameCreationWizardValidationTests
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
@@ -178,7 +177,7 @@ public sealed class GameCreationWizardValidationTests
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("CustomSystem"), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(TextInteraction("CustomSystem", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Duration, draft.Step);
}
@@ -1,7 +1,7 @@
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
@@ -30,7 +30,7 @@ public sealed class UpdateRouterDelegationTests
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var update = TextUpdate("Curse of Strahd", ownerId: draft.OwnerTelegramId);
var update = TextUpdate("Curse of Strahd", ownerId: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture));
await sut.RouteAsync(update, CancellationToken.None);
@@ -49,7 +49,7 @@ public sealed class UpdateRouterDelegationTests
// "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: draft.OwnerTelegramId);
var update = CallbackUpdate(WizardCallbackData.Cancel(), ownerId: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture));
await sut.RouteAsync(update, CancellationToken.None);
@@ -1,7 +1,7 @@
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
@@ -36,8 +36,8 @@ public sealed class UpdateRouterResetsDraftOnStaleCommandTests
Message = new Message
{
Text = "/newsession",
Chat = new Chat { Id = draft.ChatId },
From = new User { Id = draft.OwnerTelegramId, FirstName = "GM" },
Chat = new Chat { Id = long.Parse(draft.ChatId, System.Globalization.CultureInfo.InvariantCulture) },
From = new User { Id = long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture), FirstName = "GM" },
},
};
@@ -49,22 +49,23 @@ public sealed class WizardDraftRepositoryFixture : IAsyncLifetime
"""
CREATE TABLE wizard_drafts (
id UUID PRIMARY KEY,
chat_id BIGINT NOT NULL,
message_thread_id INT,
owner_telegram_id BIGINT NOT NULL,
chat_id TEXT NOT NULL,
message_thread_id TEXT,
owner_id TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT 'Telegram',
step TEXT NOT NULL,
payload JSONB NOT NULL,
draft_message_id BIGINT,
draft_message_id TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_wizard_drafts_owner
ON wizard_drafts(chat_id, message_thread_id, owner_telegram_id);
ON wizard_drafts(platform, owner_id);
CREATE INDEX idx_wizard_drafts_expires
ON wizard_drafts(expires_at);
CREATE INDEX idx_wizard_drafts_platform
ON wizard_drafts(platform);
""",
connection);
await createSchema.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
@@ -1,3 +1,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Npgsql;
@@ -20,7 +23,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1);
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, draft.OwnerTelegramId, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None);
Assert.NotNull(loaded);
Assert.Equal("Title", loaded!.Step);
}
@@ -35,7 +38,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, draft.OwnerTelegramId, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None);
Assert.Null(loaded);
}
@@ -49,7 +52,9 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, ownerTelegramId: draft.OwnerTelegramId + 1, CancellationToken.None);
var otherOwner = (long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture) + 1)
.ToString(System.Globalization.CultureInfo.InvariantCulture);
var loaded = await sut.GetActiveAsync(draft.Platform, otherOwner, CancellationToken.None);
Assert.Null(loaded);
}
@@ -69,16 +74,17 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
var deleted = await sut.DeleteExpiredAsync(CancellationToken.None);
Assert.Equal(1, deleted);
var loadedFresh = await sut.GetActiveAsync(fresh.ChatId, fresh.MessageThreadId, fresh.OwnerTelegramId, CancellationToken.None);
var loadedFresh = await sut.GetActiveAsync(fresh.Platform, fresh.OwnerId, CancellationToken.None);
Assert.NotNull(loadedFresh);
}
private static WizardDraft NewDraft(string step, DateTimeOffset expiresAt) => new()
{
Id = Guid.NewGuid(),
ChatId = 42,
ChatId = "42",
MessageThreadId = null,
OwnerTelegramId = 100,
OwnerId = "100",
Platform = "Telegram",
Step = step,
PayloadJson = "{}",
CreatedAt = DateTimeOffset.UtcNow,
@@ -0,0 +1,126 @@
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using Telegram.Bot.Types;
using Xunit;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the Telegram <c>Update</c> → <c>WizardInteraction</c> mapping
/// that <see cref="WizardInteractionMapper"/> exposes. The mapper is the
/// single bridge between Telegram's native update type and the
/// platform-neutral wizard core, so its contract needs to be locked
/// down: callback queries carry the data payload, text messages carry
/// their text, and photos carry the largest photo's <c>FileId</c>.
/// </summary>
public sealed class WizardInteractionMapperTests
{
[Fact]
public void CallbackUpdate_ProducesCallbackInteraction_WithPayloadAndOwner()
{
var update = new Update
{
CallbackQuery = new CallbackQuery
{
Id = "cb-42",
Data = "wizard:choice:Type:single",
From = new User { Id = 100, FirstName = "GM" },
Message = new Message { Chat = new Chat { Id = 42 } },
},
};
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
Assert.True(ok);
Assert.Equal("100", interaction.OwnerId);
Assert.Null(interaction.Text);
Assert.Equal("wizard:choice:Type:single", interaction.CallbackPayload);
Assert.Null(interaction.PhotoFileId);
Assert.Null(interaction.PhotoUrl);
Assert.Equal("cb-42", interaction.InteractionId);
}
[Fact]
public void TextUpdate_ProducesTextInteraction_WithTextAndNoCallback()
{
var update = new Update
{
Message = new Message
{
Text = "My Game Title",
Chat = new Chat { Id = 42 },
From = new User { Id = 200, FirstName = "GM" },
},
};
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
Assert.True(ok);
Assert.Equal("200", interaction.OwnerId);
Assert.Equal("My Game Title", interaction.Text);
Assert.Null(interaction.CallbackPayload);
Assert.Null(interaction.PhotoFileId);
Assert.Equal("msg", interaction.InteractionId);
}
[Fact]
public void PhotoUpdate_ProducesPhotoInteraction_WithLargestFileId()
{
var update = new Update
{
Message = new Message
{
Chat = new Chat { Id = 42 },
From = new User { Id = 300, FirstName = "GM" },
Photo = new[]
{
new PhotoSize { FileId = "small-id", Width = 90, Height = 60 },
new PhotoSize { FileId = "medium-id", Width = 320, Height = 240 },
new PhotoSize { FileId = "large-id", Width = 800, Height = 600 },
},
},
};
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
Assert.True(ok);
Assert.Equal("300", interaction.OwnerId);
Assert.Null(interaction.Text);
Assert.Null(interaction.CallbackPayload);
Assert.Equal("large-id", interaction.PhotoFileId);
}
[Fact]
public void CaptionedPhoto_ProducesPhotoInteraction_AndKeepsCaptionOutOfText()
{
// Telegram sometimes attaches a caption to a photo message. The
// mapper treats it as a non-text interaction (cover-step uses
// PhotoFileId, not caption). This test pins that distinction.
var update = new Update
{
Message = new Message
{
Caption = "ignored",
Chat = new Chat { Id = 42 },
From = new User { Id = 400 },
Photo = new[]
{
new PhotoSize { FileId = "only-id", Width = 100, Height = 100 },
},
},
};
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
Assert.True(ok);
Assert.Equal("only-id", interaction.PhotoFileId);
}
[Fact]
public void EmptyUpdate_ReturnsFalse()
{
var ok = WizardInteractionMapper.TryMap(new Update(), out var interaction);
Assert.False(ok);
Assert.Null(interaction);
}
}
@@ -248,7 +248,7 @@ public sealed class WizardStepRenderTests
private static WizardDraft NewDraft(string step) => new()
{
Id = Guid.NewGuid(),
ChatId = 42,
ChatId = "42",
Step = step,
PayloadJson = "{}",
};
@@ -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);
}