refactor(wizard): make CreateSessionHandler wizard-driven and remove legacy parser

This commit is contained in:
2026-06-04 09:00:37 +03:00
parent eeffae659f
commit 4a04d7d723
8 changed files with 282 additions and 538 deletions
@@ -1,4 +1,3 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
@@ -11,13 +10,17 @@ namespace GmRelay.Bot.Tests.Features.Landing;
public sealed class DiscordLandingPromisesSmokeTests
{
private sealed record SmokeParseResult(
string Title,
string Link,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes);
[Fact]
public void Smoke_ShouldCoverDiscordLandingPromisesWithoutExternalDiscordApi()
{
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
var parseResult = BuildRecurringSessionParseResult();
Assert.True(parseResult.IsValid);
Assert.Equal(3, parseResult.ScheduledTimes.Count);
Assert.Equal(2, parseResult.MaxPlayers);
Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]);
@@ -126,16 +129,17 @@ public sealed class DiscordLandingPromisesSmokeTests
Assert.Contains("Carol", firstSessionEmbed.Description);
}
private static string BuildRecurringSessionCommand() =>
string.Join(
'\n',
"/newsession",
"Название: Landing Promise Smoke",
"Время: 15.05.2026 19:30",
"Игр: 3",
"Интервал: 7",
"Мест: 2",
"Ссылка: https://example.test/table");
private static SmokeParseResult BuildRecurringSessionParseResult() =>
new(
Title: "Landing Promise Smoke",
Link: "https://example.test/table",
MaxPlayers: 2,
ScheduledTimes: new[]
{
new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero),
});
private static IReadOnlyList<string> CallbackData(IReadOnlyList<ActionRowProperties> actionRows) =>
actionRows
@@ -183,14 +187,14 @@ public sealed class DiscordLandingPromisesSmokeTests
public FakeDiscordMessage LastMessage => Messenger.LastMessage;
public static DiscordLandingSmokeScenario Publish(
NewSessionParseResult parseResult,
SmokeParseResult parseResult,
SessionNotificationMode notificationMode)
{
var scenario = new DiscordLandingSmokeScenario(
parseResult.Title!,
parseResult.Title,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
parseResult.Link!,
parseResult.Link,
notificationMode);
scenario.RenderBatch();
@@ -1,4 +1,3 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
@@ -10,13 +9,17 @@ namespace GmRelay.Bot.Tests.Features.Landing;
public sealed class TelegramLandingPromisesSmokeTests
{
private sealed record SmokeParseResult(
string Title,
string Link,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes);
[Fact]
public void Smoke_ShouldCoverTelegramLandingPromisesWithoutExternalTelegramApi()
{
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
var parseResult = BuildRecurringSessionParseResult();
Assert.True(parseResult.IsValid);
Assert.Equal(3, parseResult.ScheduledTimes.Count);
Assert.Equal(2, parseResult.MaxPlayers);
Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]);
@@ -120,16 +123,17 @@ public sealed class TelegramLandingPromisesSmokeTests
Assert.Contains("@carol", scenario.LastMessage.Text);
}
private static string BuildRecurringSessionCommand() =>
string.Join(
'\n',
"/newsession",
"\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435: Landing Promise Smoke",
"\u0412\u0440\u0435\u043c\u044f: 15.05.2026 19:30",
"\u0418\u0433\u0440: 3",
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b: 7",
"\u041c\u0435\u0441\u0442: 2",
"\u0421\u0441\u044b\u043b\u043a\u0430: https://example.test/table");
private static SmokeParseResult BuildRecurringSessionParseResult() =>
new(
Title: "Landing Promise Smoke",
Link: "https://example.test/table",
MaxPlayers: 2,
ScheduledTimes: new[]
{
new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero),
});
private static IReadOnlyList<string> CallbackData(InlineKeyboardMarkup markup) =>
markup.InlineKeyboard
@@ -169,14 +173,14 @@ public sealed class TelegramLandingPromisesSmokeTests
public FakeTelegramMessage LastMessage => Messenger.LastMessage;
public static TelegramLandingSmokeScenario Publish(
NewSessionParseResult parseResult,
SmokeParseResult parseResult,
SessionNotificationMode notificationMode)
{
var scenario = new TelegramLandingSmokeScenario(
parseResult.Title!,
parseResult.Title,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
parseResult.Link!,
parseResult.Link,
notificationMode);
scenario.RenderBatch();
@@ -1,152 +0,0 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class NewSessionCommandParserTests
{
[Fact]
public void Parse_ShouldExtractTitleLinkAndUpcomingTimes()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Curse of Strahd
Время: 24.04.2026 19:30
Время: 01.05.2026 20:00
Мест: 4
Ссылка: https://example.test/room
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal("Curse of Strahd", result.Title);
Assert.Equal("https://example.test/room", result.Link);
Assert.Equal(4, result.MaxPlayers);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 24, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 1, 17, 0, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
Assert.Empty(result.PastTimeInputs);
Assert.Empty(result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldExtractOptionalImageUrl()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Curse of Strahd
Время: 24.04.2026 19:30
Ссылка: https://example.test/room
Картинка: https://example.test/strahd.jpg
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal("https://example.test/strahd.jpg", result.ImageUrl);
}
[Fact]
public void GetBatchImageReference_ShouldPreferAttachedPhotoOverParsedUrl()
{
var message = new Message
{
Photo =
[
new PhotoSize { FileId = "small-photo", Width = 320, Height = 180, FileSize = 10 },
new PhotoSize { FileId = "large-photo", Width = 1280, Height = 720, FileSize = 20 }
]
};
var imageReference = CreateSessionHandler.GetBatchImageReference(
message,
"https://example.test/cover.jpg");
Assert.Equal("large-photo", imageReference);
}
[Fact]
public void Parse_ShouldExpandRecurringSchedule_WhenRepeatCountAndIntervalProvided()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Kingmaker
Время: 30.04.2026 19:30
Игр: 4
Интервал: 14
Ссылка: https://example.test/kingmaker
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 30, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 14, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 28, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 6, 11, 16, 30, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
}
[Fact]
public void Parse_ShouldCollectPastAndInvalidTimes()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
Название: Delta Green
Время: 20.04.2026 19:30
Время: 31.04.2026 19:30
Время: 25.04.2026 18:00
Ссылка: https://example.test/dg
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Single(result.ScheduledTimes);
Assert.Equal(["20.04.2026 19:30"], result.PastTimeInputs);
Assert.Equal(["31.04.2026 19:30"], result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldBeInvalid_WhenRequiredFieldsMissing()
{
var text = """
/newsession
Название: Blades in the Dark
Время: 25.04.2026 19:30
""";
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
Assert.False(result.IsValid);
Assert.Null(result.Link);
}
[Fact]
public void Parse_ShouldCollectInvalidSeatLimit()
{
var text = """
/newsession
Название: Blades in the Dark
Время: 25.04.2026 19:30
Мест: 0
Ссылка: https://example.test/blades
""";
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
Assert.False(result.IsValid);
Assert.Null(result.MaxPlayers);
Assert.Equal(["0"], result.InvalidSeatLimitInputs);
}
}
@@ -8,12 +8,18 @@ public sealed class TelegramTopicIntegrationSmokeTests
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V015__add_topic_ownership.sql");
var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs");
var deleteHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs");
var topicRouting = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramTopicRouting.cs");
Assert.Contains("topic_created_by_bot", migration, StringComparison.Ordinal);
Assert.Contains("ResolveNewScheduleDestination", createHandler, StringComparison.Ordinal);
Assert.Contains("message.MessageThreadId", createHandler, StringComparison.Ordinal);
Assert.Contains("topicCreatedByBot", createHandler, StringComparison.Ordinal);
Assert.Contains("MissingForumTopicRightsMessage", createHandler, StringComparison.Ordinal);
// The wizard-driven CreateSessionHandler threads the existing forum topic
// (if any) into the draft; the shared creation command inherits it. Topic
// auto-creation and rights handling live in TelegramTopicRouting.
Assert.Contains("MessageThreadId", createHandler, StringComparison.Ordinal);
Assert.Contains("ExternalThreadId", createHandler, StringComparison.Ordinal);
Assert.Contains("ResolveNewScheduleDestination", topicRouting, StringComparison.Ordinal);
Assert.Contains("MissingForumTopicRightsMessage", topicRouting, StringComparison.Ordinal);
Assert.Contains("TopicCreatedByBot", deleteHandler, StringComparison.Ordinal);
Assert.Contains("ShouldDeleteForumTopic", deleteHandler, StringComparison.Ordinal);
Assert.Contains("RemainingInTopic", deleteHandler, StringComparison.Ordinal);