feat(bot): add online/offline wizard locations
PR Checks / test-and-build (pull_request) Successful in 15m52s
PR Checks / test-and-build (pull_request) Successful in 15m52s
Add format and location steps to the Telegram /newsession wizard, persist offline addresses in sessions.location_address, and render online links/offline addresses in schedule messages. Bump version to 3.10.0.
This commit is contained in:
+19
-2
@@ -95,7 +95,7 @@ public sealed class CreateSessionHandlerIntegrationTests(CreateSessionHandlerPos
|
||||
new PlatformUser(PlatformKind.Telegram, "111111111", "Test GM", "test_gm"),
|
||||
new PlatformGroup(PlatformKind.Telegram, "222222222", "Test Group"),
|
||||
"Test Adventure",
|
||||
string.Empty,
|
||||
"https://vtt.example/game",
|
||||
[DateTimeOffset.UtcNow.AddDays(1)],
|
||||
null,
|
||||
null,
|
||||
@@ -103,7 +103,8 @@ public sealed class CreateSessionHandlerIntegrationTests(CreateSessionHandlerPos
|
||||
"Integration regression test",
|
||||
"Online",
|
||||
240,
|
||||
true),
|
||||
true,
|
||||
"Online room notes"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
@@ -126,5 +127,21 @@ public sealed class CreateSessionHandlerIntegrationTests(CreateSessionHandlerPos
|
||||
|
||||
var ownerCount = (long)(await command.ExecuteScalarAsync() ?? 0L);
|
||||
Assert.Equal(1, ownerCount);
|
||||
|
||||
await using var sessionCommand = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT join_link, format, location_address
|
||||
FROM sessions
|
||||
WHERE batch_id = @batch_id
|
||||
""",
|
||||
connection);
|
||||
sessionCommand.Parameters.AddWithValue("batch_id", result.BatchId.Value);
|
||||
|
||||
await using var reader = await sessionCommand.ExecuteReaderAsync();
|
||||
Assert.True(await reader.ReadAsync());
|
||||
Assert.Equal("https://vtt.example/game", reader.GetString(0));
|
||||
Assert.Equal("Online", reader.GetString(1));
|
||||
Assert.Equal("Online room notes", reader.GetString(2));
|
||||
Assert.False(await reader.ReadAsync());
|
||||
}
|
||||
}
|
||||
|
||||
+76
@@ -82,4 +82,80 @@ public sealed class CreateSessionHandlerBuildCommandTests
|
||||
|
||||
Assert.Equal(5, cmd.MaxPlayers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCommand_WhenFormatIsOnline_PropagatesFormatAndJoinLink()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = 4,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = CreateSessionHandler.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Equal("Online", cmd.Format);
|
||||
Assert.Equal("https://vtt.example/game", cmd.Link);
|
||||
Assert.Null(cmd.LocationAddress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCommand_WhenFormatIsOffline_PropagatesFormatAndAddress()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Format = WizardSessionFormat.Offline,
|
||||
LocationAddress = "Москва, ул. Кубиков, 12",
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = 4,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = CreateSessionHandler.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Equal("Offline", cmd.Format);
|
||||
Assert.Equal(string.Empty, cmd.Link);
|
||||
Assert.Equal("Москва, ул. Кубиков, 12", cmd.LocationAddress);
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -33,6 +33,8 @@ public sealed class CreateSessionHandlerSubmitSingleDraftTests(CreateSessionHand
|
||||
Title = "Тест публикации",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
|
||||
+10
@@ -36,6 +36,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||
@@ -69,6 +71,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
DurationMinutes = 240,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
@@ -104,6 +108,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput { MaxPlayers = 4 },
|
||||
};
|
||||
@@ -135,6 +141,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
||||
Title = "P",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Visibility = WizardVisibility.Public,
|
||||
Pool = new WizardPoolInput(),
|
||||
};
|
||||
@@ -168,6 +176,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
|
||||
+10
-3
@@ -84,17 +84,24 @@ public sealed class GameCreationWizardCancelBackTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Back_FromPoolAddSlots_GoesToPoolSystemDuration()
|
||||
public async Task Back_FromPoolAddSlots_GoesToVisibility()
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var draft = NewDraft(WizardStepNames.PoolAddSlots,
|
||||
new WizardPayload { Type = WizardCreationType.Pool, Title = "Pool" });
|
||||
new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Pool,
|
||||
Title = "Pool",
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Visibility = WizardVisibility.Public,
|
||||
});
|
||||
drafts.Seed(draft);
|
||||
|
||||
var data = WizardCallbackData.Back();
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
|
||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+87
-5
@@ -20,8 +20,8 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
|
||||
// Duration → DateTime (single, no maxPlayers yet)
|
||||
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
|
||||
// Capacity → Visibility (only explicit no-limit can skip numeric capacity)
|
||||
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Visibility)]
|
||||
// Capacity → Format (only explicit no-limit can skip numeric capacity)
|
||||
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Format)]
|
||||
// Visibility → Publish (public, no club)
|
||||
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
|
||||
// Visibility → PickClub
|
||||
@@ -46,7 +46,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PoolSystemDuration_PreselectedButton_AdvancesToVisibility()
|
||||
public async Task PoolSystemDuration_PreselectedButton_AdvancesToFormat()
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var payload = new WizardPayload
|
||||
@@ -60,7 +60,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||
Assert.Equal(WizardStepNames.Format, draft.Step);
|
||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("system", out var sys));
|
||||
@@ -79,7 +79,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit");
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||
Assert.Equal(WizardStepNames.Format, draft.Step);
|
||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("single", out var single));
|
||||
@@ -111,6 +111,78 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
Assert.Equal(WizardStepNames.System, draft.Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Format_OnlineChoice_AdvancesToLocationAndPersistsFormat()
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var draft = NewDraft(WizardStepNames.Format, PayloadForStep(WizardStepNames.Format));
|
||||
drafts.Seed(draft);
|
||||
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.Format, "online");
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Location, draft.Step);
|
||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("format", out var format));
|
||||
Assert.Equal("Online", format.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Format_OfflineChoice_AdvancesToLocationAndPersistsFormat()
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var draft = NewDraft(WizardStepNames.Format, PayloadForStep(WizardStepNames.Format));
|
||||
drafts.Seed(draft);
|
||||
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.Format, "offline");
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Location, draft.Step);
|
||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("format", out var format));
|
||||
Assert.Equal("Offline", format.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Location_TextForOnline_StoresJoinLinkAndAdvancesToVisibility()
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var payload = PayloadForStep(WizardStepNames.Location);
|
||||
payload.Format = WizardSessionFormat.Online;
|
||||
var draft = NewDraft(WizardStepNames.Location, payload);
|
||||
drafts.Seed(draft);
|
||||
|
||||
await wizard.HandleInteractionAsync(TextInteraction("https://vtt.example/game", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("joinLink", out var joinLink));
|
||||
Assert.Equal("https://vtt.example/game", joinLink.GetString());
|
||||
Assert.False(root.TryGetProperty("locationAddress", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Location_TextForOffline_StoresAddressAndAdvancesToVisibility()
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var payload = PayloadForStep(WizardStepNames.Location);
|
||||
payload.Format = WizardSessionFormat.Offline;
|
||||
var draft = NewDraft(WizardStepNames.Location, payload);
|
||||
drafts.Seed(draft);
|
||||
|
||||
await wizard.HandleInteractionAsync(TextInteraction("Москва, ул. Кубиков, 12", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("locationAddress", out var address));
|
||||
Assert.Equal("Москва, ул. Кубиков, 12", address.GetString());
|
||||
Assert.False(root.TryGetProperty("joinLink", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick()
|
||||
{
|
||||
@@ -182,6 +254,16 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
},
|
||||
WizardStepNames.Format or WizardStepNames.Location => new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
|
||||
},
|
||||
WizardStepNames.PickClub => new WizardPayload
|
||||
{
|
||||
|
||||
+37
@@ -79,6 +79,39 @@ public sealed class WizardStepRenderTests
|
||||
Assert.Contains(labels, l => l.Contains("Без лимита", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatStep_HasOnlineAndOfflineButtons()
|
||||
{
|
||||
var (text, kb) = Render(WizardStepNames.Format);
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(text));
|
||||
var labels = ButtonLabels(kb);
|
||||
Assert.Contains(labels, l => l.Contains("Online", StringComparison.Ordinal));
|
||||
Assert.Contains(labels, l => l.Contains("Offline", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocationStep_ForOnline_AsksForLink()
|
||||
{
|
||||
var (text, kb) = Render(WizardStepNames.Location, new WizardPayload { Format = WizardSessionFormat.Online });
|
||||
|
||||
Assert.Contains("ссыл", text, StringComparison.OrdinalIgnoreCase);
|
||||
var labels = ButtonLabels(kb);
|
||||
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
|
||||
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocationStep_ForOffline_AsksForAddress()
|
||||
{
|
||||
var (text, kb) = Render(WizardStepNames.Location, new WizardPayload { Format = WizardSessionFormat.Offline });
|
||||
|
||||
Assert.Contains("адрес", text, StringComparison.OrdinalIgnoreCase);
|
||||
var labels = ButtonLabels(kb);
|
||||
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
|
||||
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VisibilityStep_HasAllFourVisibilityOptions()
|
||||
{
|
||||
@@ -135,10 +168,14 @@ public sealed class WizardStepRenderTests
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "My Game",
|
||||
Format = WizardSessionFormat.Offline,
|
||||
LocationAddress = "Москва, ул. Кубиков, 12",
|
||||
});
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(text));
|
||||
Assert.Contains("My Game", text);
|
||||
Assert.Contains("Offline", text);
|
||||
Assert.Contains("Москва, ул. Кубиков, 12", text);
|
||||
var labels = ButtonLabels(kb);
|
||||
Assert.Contains(labels, l => l.Contains("Создать", StringComparison.Ordinal));
|
||||
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
|
||||
|
||||
@@ -135,6 +135,56 @@ public sealed class TelegramSessionBatchRendererTests
|
||||
Assert.Equal(2, buttons.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldShowOfflineAddress()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionBatchDto(
|
||||
sessionId,
|
||||
DateTime.UtcNow,
|
||||
SessionStatus.Planned,
|
||||
4,
|
||||
"",
|
||||
"Offline",
|
||||
"Москва, ул. Кубиков, 12"),
|
||||
};
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Offline Test", sessions, participants);
|
||||
var (text, _) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Contains("📍 Адрес:", text);
|
||||
Assert.Contains("Москва, ул. Кубиков, 12", text);
|
||||
Assert.DoesNotContain("Ссылка на игру", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldShowOnlineLinkWithLinkIcon()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionBatchDto(
|
||||
sessionId,
|
||||
DateTime.UtcNow,
|
||||
SessionStatus.Planned,
|
||||
4,
|
||||
"https://vtt.example/game",
|
||||
"Online",
|
||||
null),
|
||||
};
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Online Test", sessions, participants);
|
||||
var (text, _) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Contains("🔗 Ссылка на игру", text);
|
||||
Assert.Contains("https://vtt.example/game", text);
|
||||
Assert.DoesNotContain("📍 Адрес:", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldEncodeHtmlInJoinLink()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user